diff --git a/src/main/java/com/iemr/common/controller/videocall/VideoCallController.java b/src/main/java/com/iemr/common/controller/videocall/VideoCallController.java index bf4f65a4..436b80a9 100644 --- a/src/main/java/com/iemr/common/controller/videocall/VideoCallController.java +++ b/src/main/java/com/iemr/common/controller/videocall/VideoCallController.java @@ -126,6 +126,49 @@ public ResponseEntity updateCallStatus(@RequestBody UpdateCallRequest re return ResponseEntity.ok(response.toString()); } +/** + * Returns a moderator JWT URL for the agent so they can use "End Meeting for All". + * Called by the frontend after the meeting link is generated. + */ +@PostMapping(value = "/agent-token", produces = MediaType.APPLICATION_JSON_VALUE, headers = "Authorization") +public ResponseEntity> generateAgentToken(@RequestBody Map body) { + Map response = new HashMap<>(); + try { + String slug = body.get("slug"); + String agentName = body.get("agentName"); + String agentEmail = body.get("agentEmail"); + + if (slug == null || slug.isEmpty()) { + response.put("error", "slug is required"); + return ResponseEntity.badRequest().body(response); + } + + String agentUrl = videoCallService.generateAgentToken(slug, agentName, agentEmail); + response.put("agentMeetingUrl", agentUrl); + + // Parse roomName and jwt out of the URL so the frontend can pass them + // directly to JitsiMeetExternalAPI without re-parsing the URL itself. + // URL format: https:///?jwt= + int jwtIdx = agentUrl.lastIndexOf("?jwt="); + if (jwtIdx > 0) { + String jwt = agentUrl.substring(jwtIdx + 5); + String pathPart = agentUrl.substring(0, jwtIdx); + String roomName = pathPart.substring(pathPart.lastIndexOf('/') + 1); + response.put("roomName", roomName); + response.put("jwt", jwt); + } + + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + response.put("error", e.getMessage()); + return ResponseEntity.badRequest().body(response); + } catch (Exception e) { + logger.error("generateAgentToken failed: {}", e.getMessage(), e); + response.put("error", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} + /** * Public redirect endpoint hit when a beneficiary clicks the short SMS link. * diff --git a/src/main/java/com/iemr/common/service/videocall/VideoCallService.java b/src/main/java/com/iemr/common/service/videocall/VideoCallService.java index 096c000b..81975d53 100644 --- a/src/main/java/com/iemr/common/service/videocall/VideoCallService.java +++ b/src/main/java/com/iemr/common/service/videocall/VideoCallService.java @@ -44,4 +44,15 @@ public interface VideoCallService { * https://<jitsi.domain>/<jitsi.room.prefix><slug>?jwt=<token> */ public String resolveMeetingLink(String slug) throws Exception; + + /** + * Generate a moderator JWT URL for the agent/associate so they can join + * the Jitsi room with "End Meeting for All" privileges. + * + * @param slug the meeting slug (value after "m=" in the meeting link) + * @param agentName display name for the agent in the Jitsi UI + * @param agentEmail agent email (used for Jitsi avatar / gravatar) + * @return absolute Jitsi URL with moderator JWT appended + */ + public String generateAgentToken(String slug, String agentName, String agentEmail) throws Exception; } diff --git a/src/main/java/com/iemr/common/service/videocall/VideoCallServiceImpl.java b/src/main/java/com/iemr/common/service/videocall/VideoCallServiceImpl.java index 48322ef6..036eee80 100644 --- a/src/main/java/com/iemr/common/service/videocall/VideoCallServiceImpl.java +++ b/src/main/java/com/iemr/common/service/videocall/VideoCallServiceImpl.java @@ -196,12 +196,29 @@ public String resolveMeetingLink(String slug) throws Exception { ? params.getAgentName() : "Guest"; - String token = jitsiJwtUtil.generateRoomToken(roomName, userName, defaultUserEmail); + String token = jitsiJwtUtil.generateRoomToken(roomName, userName, defaultUserEmail, false); String redirectUrl = "https://" + jitsiDomain + "/" + roomName + "?jwt=" + token; return redirectUrl; } +@Override +public String generateAgentToken(String slug, String agentName, String agentEmail) throws Exception { + if (slug == null || slug.isEmpty()) { + throw new IllegalArgumentException("Meeting slug is required"); + } + + // Room name is deterministic from the slug — no DB lookup needed. + // This avoids a race condition where the frontend calls this endpoint + // before /send-link has written the row. + String roomName = roomPrefix + slug; + String displayName = (agentName != null && !agentName.isEmpty()) ? agentName : "Agent"; + String email = (agentEmail != null && !agentEmail.isEmpty()) ? agentEmail : defaultUserEmail; + + String token = jitsiJwtUtil.generateRoomToken(roomName, displayName, email, true); + return "https://" + jitsiDomain + "/" + roomName + "?jwt=" + token; +} + } diff --git a/src/main/java/com/iemr/common/utils/JitsiJwtUtil.java b/src/main/java/com/iemr/common/utils/JitsiJwtUtil.java index 75591633..229a77f1 100644 --- a/src/main/java/com/iemr/common/utils/JitsiJwtUtil.java +++ b/src/main/java/com/iemr/common/utils/JitsiJwtUtil.java @@ -75,12 +75,13 @@ private SecretKey getSigningKey() { /** * Build a Jitsi room JWT. * - * @param room the exact room name the bearer will join (must match the URL path) - * @param userName display name shown in the Jitsi UI - * @param userEmail email shown in the Jitsi UI (used for gravatar etc.) + * @param room the exact room name the bearer will join (must match the URL path) + * @param userName display name shown in the Jitsi UI + * @param userEmail email shown in the Jitsi UI (used for gravatar etc.) + * @param isModerator when true, grants prosody moderator role — required for "End Meeting for All" * @return signed compact JWT string */ - public String generateRoomToken(String room, String userName, String userEmail) { + public String generateRoomToken(String room, String userName, String userEmail, boolean isModerator) { if (room == null || room.isEmpty()) { throw new IllegalArgumentException("room is required to mint a Jitsi token"); } @@ -91,6 +92,7 @@ public String generateRoomToken(String room, String userName, String userEmail) Map user = new HashMap<>(); user.put("name", userName != null ? userName : "Guest"); user.put("email", userEmail != null ? userEmail : ""); + user.put("moderator", isModerator); Map context = new HashMap<>(); context.put("user", user); diff --git a/src/test/java/com/iemr/common/service/videocall/VideoCallServiceImplTest.java b/src/test/java/com/iemr/common/service/videocall/VideoCallServiceImplTest.java index 2723fda0..f8ef8add 100644 --- a/src/test/java/com/iemr/common/service/videocall/VideoCallServiceImplTest.java +++ b/src/test/java/com/iemr/common/service/videocall/VideoCallServiceImplTest.java @@ -189,7 +189,8 @@ public void testResolveMeetingLink_success() throws Exception { when(jitsiJwtUtil.generateRoomToken( eq("piramal-meeting-Ab3xQ9pK"), eq("Dr. Asha"), - eq("admin@piramalswasthya.org"))).thenReturn("FAKE.JWT.TOKEN"); + eq("admin@piramalswasthya.org"), + eq(false))).thenReturn("FAKE.JWT.TOKEN"); String result = service.resolveMeetingLink("Ab3xQ9pK"); @@ -197,7 +198,7 @@ public void testResolveMeetingLink_success() throws Exception { "https://meet.jit.si/piramal-meeting-Ab3xQ9pK?jwt=FAKE.JWT.TOKEN", result); verify(jitsiJwtUtil).generateRoomToken( - "piramal-meeting-Ab3xQ9pK", "Dr. Asha", "admin@piramalswasthya.org"); + "piramal-meeting-Ab3xQ9pK", "Dr. Asha", "admin@piramalswasthya.org", false); } @Test @@ -235,13 +236,14 @@ public void testResolveMeetingLink_fallbackUserNameWhenAgentMissing() throws Exc when(jitsiJwtUtil.generateRoomToken( eq("piramal-meeting-Ab3xQ9pK"), eq("Guest"), - eq("admin@piramalswasthya.org"))).thenReturn("FAKE.JWT.TOKEN"); + eq("admin@piramalswasthya.org"), + eq(false))).thenReturn("FAKE.JWT.TOKEN"); String result = service.resolveMeetingLink("Ab3xQ9pK"); assertTrue(result.endsWith("?jwt=FAKE.JWT.TOKEN")); verify(jitsiJwtUtil).generateRoomToken( - "piramal-meeting-Ab3xQ9pK", "Guest", "admin@piramalswasthya.org"); + "piramal-meeting-Ab3xQ9pK", "Guest", "admin@piramalswasthya.org", false); } @Test diff --git a/src/test/java/com/iemr/common/utils/JitsiJwtUtilTest.java b/src/test/java/com/iemr/common/utils/JitsiJwtUtilTest.java index c300e27b..3d89480b 100644 --- a/src/test/java/com/iemr/common/utils/JitsiJwtUtilTest.java +++ b/src/test/java/com/iemr/common/utils/JitsiJwtUtilTest.java @@ -57,7 +57,7 @@ void setUp() { @Test void generateRoomToken_producesAllRequiredClaims() { - String token = util.generateRoomToken("piramal-meeting-Ab3xQ9pK", "Dr. Asha", "asha@piramalswasthya.org"); + String token = util.generateRoomToken("piramal-meeting-Ab3xQ9pK", "Dr. Asha", "asha@piramalswasthya.org", false); assertNotNull(token); assertTrue(token.split("\\.").length == 3, "JWT should have 3 dot-separated parts"); @@ -81,15 +81,33 @@ void generateRoomToken_producesAllRequiredClaims() { assertNotNull(user); assertEquals("Dr. Asha", user.get("name")); assertEquals("asha@piramalswasthya.org", user.get("email")); + assertEquals(false, user.get("moderator")); Date exp = claims.getExpiration(); assertNotNull(exp); assertTrue(exp.after(new Date()), "exp should be in the future"); } + @Test + void generateRoomToken_moderatorClaimTrueForAgent() { + String token = util.generateRoomToken("piramal-meeting-Ab3xQ9pK", "Dr. Asha", "asha@piramalswasthya.org", true); + + Claims claims = Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(APP_SECRET.getBytes())) + .build() + .parseSignedClaims(token) + .getPayload(); + + @SuppressWarnings("unchecked") + Map context = claims.get("context", Map.class); + @SuppressWarnings("unchecked") + Map user = (Map) context.get("user"); + assertEquals(true, user.get("moderator")); + } + @Test void generateRoomToken_fallsBackToGuestWhenUserNameNull() { - String token = util.generateRoomToken("piramal-meeting-xyz", null, null); + String token = util.generateRoomToken("piramal-meeting-xyz", null, null, false); Claims claims = Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(APP_SECRET.getBytes())) @@ -108,19 +126,19 @@ void generateRoomToken_fallsBackToGuestWhenUserNameNull() { @Test void generateRoomToken_rejectsEmptyRoom() { assertThrows(IllegalArgumentException.class, - () -> util.generateRoomToken("", "Dr. Asha", "asha@piramalswasthya.org")); + () -> util.generateRoomToken("", "Dr. Asha", "asha@piramalswasthya.org", false)); } @Test void generateRoomToken_rejectsNullRoom() { assertThrows(IllegalArgumentException.class, - () -> util.generateRoomToken(null, "Dr. Asha", "asha@piramalswasthya.org")); + () -> util.generateRoomToken(null, "Dr. Asha", "asha@piramalswasthya.org", false)); } @Test void generateRoomToken_failsWhenAppSecretMissing() { ReflectionTestUtils.setField(util, "appSecret", ""); assertThrows(IllegalStateException.class, - () -> util.generateRoomToken("piramal-meeting-xyz", "Dr. Asha", "asha@piramalswasthya.org")); + () -> util.generateRoomToken("piramal-meeting-xyz", "Dr. Asha", "asha@piramalswasthya.org", false)); } }