Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
response.put("meetingLink", link);
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("error", e.getMessage());

Check failure on line 68 in src/main/java/com/iemr/common/controller/videocall/VideoCallController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "error" 4 times.

See more on https://sonarcloud.io/project/issues?id=PSMRI_Common-API&issues=AZ4XBnkyOQWNadg9Q8BQ&open=AZ4XBnkyOQWNadg9Q8BQ&pullRequest=407
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
Expand Down Expand Up @@ -126,6 +126,49 @@
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<Map<String, String>> generateAgentToken(@RequestBody Map<String, String> body) {
Map<String, String> 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://<domain>/<roomName>?jwt=<token>
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,15 @@
* https://&lt;jitsi.domain&gt;/&lt;jitsi.room.prefix&gt;&lt;slug&gt;?jwt=&lt;token&gt;
*/
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;

Check warning on line 57 in src/main/java/com/iemr/common/service/videocall/VideoCallService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace generic exceptions with specific library exceptions or a custom exception.

See more on https://sonarcloud.io/project/issues?id=PSMRI_Common-API&issues=AZ4XBnhBOQWNadg9Q8BP&open=AZ4XBnhBOQWNadg9Q8BP&pullRequest=407
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}


10 changes: 6 additions & 4 deletions src/main/java/com/iemr/common/utils/JitsiJwtUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -91,6 +92,7 @@ public String generateRoomToken(String room, String userName, String userEmail)
Map<String, Object> user = new HashMap<>();
user.put("name", userName != null ? userName : "Guest");
user.put("email", userEmail != null ? userEmail : "");
user.put("moderator", isModerator);

Map<String, Object> context = new HashMap<>();
context.put("user", user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,16 @@ 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");

assertEquals(
"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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions src/test/java/com/iemr/common/utils/JitsiJwtUtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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<String, Object> context = claims.get("context", Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> user = (Map<String, Object>) 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()))
Expand All @@ -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));
}
}
Loading