diff --git a/AGENTS.md b/AGENTS.md index fb773d10..f4da9b61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,12 @@ Entity-removal helpers (run before any large cleanup): - Frontend templates/static assets: [src/main/resources/templates/](src/main/resources/templates/) (per-domain + layouts) and [src/main/resources/static/](src/main/resources/static/); Backend UI notes in [README.md](README.md) - Testing/build verification: commands above + Verification section; run `./mvnw clean compile` and `./mvnw test` before schema or auth changes +## Groups & realtime chat + +- Course groups are the canonical uppercase list `CSA, CSP, CSH, CSSE`, defined in `CLASS_GROUP_NAMES` inside [ClassGroupMembershipService.java](src/main/java/com/open/spring/mvc/groups/ClassGroupMembershipService.java) — despite comments/tests referencing `CourseGroupInitializer` as its owner. Profile class selection syncs via `PUT /api/groups/class-memberships` (`GroupsApiController` → `ClassGroupMembershipService.syncMemberships`), which only adds/removes membership in those four groups and leaves all other groups untouched. +- Realtime chat uses STOMP over a **second connector on port 8589**: broker config in `WebSocketBrokerConfig.java` (`/ws-chat`, `/app`, `/topic`), port gating in `ChatWebSocketPortFilter.java`, presence in `GroupChatPresenceService.java`. The native `/websocket` endpoint in `mvc/mortevision/nativesocket/WebSocketConfig.java` is separate. +- Gotcha: Java package is `mvc.groups` (plural) but templates live in `templates/group/` (singular); static JS exists both as legacy files in `static/js/group-*.js` and packaged versions in `static/js/group/*.js` — prefer the latter. + ## Architecture Single Spring Boot app, root package `com.open.spring`, three layers: diff --git a/src/main/java/com/open/spring/mvc/groups/ClassGroupMembershipService.java b/src/main/java/com/open/spring/mvc/groups/ClassGroupMembershipService.java new file mode 100644 index 00000000..c7da93fc --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/ClassGroupMembershipService.java @@ -0,0 +1,116 @@ +package com.open.spring.mvc.groups; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.open.spring.mvc.person.Person; +import com.open.spring.mvc.person.PersonJpaRepository; + +@Service +public class ClassGroupMembershipService { + static final List CLASS_GROUP_NAMES = List.of("CSA", "CSP", "CSH", "CSSE"); + + private final GroupsJpaRepository groupsRepository; + private final PersonJpaRepository personRepository; + + public ClassGroupMembershipService( + GroupsJpaRepository groupsRepository, + PersonJpaRepository personRepository) { + this.groupsRepository = groupsRepository; + this.personRepository = personRepository; + } + + /** + * Makes the authenticated person's course-group memberships match their + * profile classes. Memberships in unrelated groups are left untouched. + */ + @Transactional + public List syncMemberships(String uid, List classes) { + if (uid == null || uid.isBlank()) { + throw new IllegalArgumentException("A user id is required"); + } + + Person person = personRepository.findByUid(uid); + if (person == null) { + throw new NoSuchElementException("Authenticated user was not found"); + } + + Set requestedGroups = normalizeClasses(classes); + Map courseGroups = loadCourseGroups(); + + for (String requestedGroup : requestedGroups) { + if (courseGroups.get(requestedGroup) == null) { + throw new NoSuchElementException( + "Course group '" + requestedGroup + "' was not found" + ); + } + } + + List changedGroups = new ArrayList<>(); + for (String groupName : CLASS_GROUP_NAMES) { + Groups group = courseGroups.get(groupName); + if (group == null) { + continue; + } + + boolean shouldBeMember = requestedGroups.contains(groupName); + boolean isMember = group.getGroupMembers().contains(person); + + if (shouldBeMember && !isMember) { + group.addPerson(person); + changedGroups.add(group); + } else if (!shouldBeMember && isMember) { + group.removePerson(person); + changedGroups.add(group); + } + } + + if (!changedGroups.isEmpty()) { + groupsRepository.saveAll(changedGroups); + } + + return CLASS_GROUP_NAMES.stream() + .filter(requestedGroups::contains) + .toList(); + } + + private Set normalizeClasses(List classes) { + Set normalizedClasses = new LinkedHashSet<>(); + if (classes == null) { + return normalizedClasses; + } + + for (String className : classes) { + if (className == null || className.isBlank()) { + throw new IllegalArgumentException("Class names cannot be blank"); + } + + String normalizedClass = className.trim().toUpperCase(Locale.ROOT); + if (!CLASS_GROUP_NAMES.contains(normalizedClass)) { + throw new IllegalArgumentException( + "Unsupported class '" + className + "'" + ); + } + normalizedClasses.add(normalizedClass); + } + + return normalizedClasses; + } + + private Map loadCourseGroups() { + Map courseGroups = new LinkedHashMap<>(); + for (String groupName : CLASS_GROUP_NAMES) { + courseGroups.put(groupName, groupsRepository.findByName(groupName).orElse(null)); + } + return courseGroups; + } +} diff --git a/src/main/java/com/open/spring/mvc/groups/GroupsApiController.java b/src/main/java/com/open/spring/mvc/groups/GroupsApiController.java index 373b4dd9..1454ca33 100644 --- a/src/main/java/com/open/spring/mvc/groups/GroupsApiController.java +++ b/src/main/java/com/open/spring/mvc/groups/GroupsApiController.java @@ -9,6 +9,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -39,6 +41,9 @@ public class GroupsApiController { @Autowired private GroupChatService groupChatService; + @Autowired + private ClassGroupMembershipService classGroupMembershipService; + // ===== DTOs ===== @Data @NoArgsConstructor @@ -66,6 +71,13 @@ public static class BulkGroupCreateDto { private List groups; } + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ClassMembershipSyncDto { + private List classes; + } + // ===== Helper Methods ===== private Map buildGroupResponse(Groups group) { Map groupMap = new LinkedHashMap<>(); @@ -306,6 +318,34 @@ public ResponseEntity> bulkCreateGroups(@RequestBody BulkGro // ===== PUT Operations ===== + /** + * PUT /api/groups/class-memberships - Synchronize the current user's course + * groups with the classes selected on their profile. + */ + @PutMapping("/class-memberships") + public ResponseEntity> syncClassMemberships( + @AuthenticationPrincipal UserDetails userDetails, + @RequestBody ClassMembershipSyncDto dto) { + if (userDetails == null) { + return new ResponseEntity<>( + Map.of("error", "Authentication is required"), + HttpStatus.UNAUTHORIZED + ); + } + + try { + List memberships = classGroupMembershipService.syncMemberships( + userDetails.getUsername(), + dto.getClasses() + ); + return new ResponseEntity<>(Map.of("groups", memberships), HttpStatus.OK); + } catch (IllegalArgumentException e) { + return new ResponseEntity<>(Map.of("error", e.getMessage()), HttpStatus.BAD_REQUEST); + } catch (java.util.NoSuchElementException e) { + return new ResponseEntity<>(Map.of("error", e.getMessage()), HttpStatus.NOT_FOUND); + } + } + /** * PUT /api/groups/{id} - Update group name and/or period * Request body: { "name": "newname", "period": "2" } @@ -644,4 +684,4 @@ private void upsertGradeEntry(List> grades, Map announcements = Announcement.init(); for (Announcement announcement : announcements) { diff --git a/src/main/resources/templates/group/group.html b/src/main/resources/templates/group/group.html index ef50c620..3712ad4d 100644 --- a/src/main/resources/templates/group/group.html +++ b/src/main/resources/templates/group/group.html @@ -152,25 +152,33 @@ - - + + + @@ -502,37 +510,44 @@ }); // Create group handler - document.getElementById("createGroupBtn").addEventListener("click", function () { - const groupName = document.getElementById("groupNameInput").value; - const groupPeriod = document.getElementById("groupPeriodInput").value; + document.getElementById("createGroupForm").addEventListener("submit", async function (event) { + event.preventDefault(); - if (!groupName || groupName.trim() === "") { - alert("Please enter a group name."); - return; - } + const groupName = document.getElementById("groupNameInput").value.trim(); + const groupPeriod = document.getElementById("groupPeriodInput").value.trim(); + const groupCourse = document.getElementById("groupCourseInput").value.trim(); + const createButton = document.getElementById("createGroupBtn"); + const errorElement = document.getElementById("createGroupError"); - // Create group with name and period only - fetch(javaURL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: groupName, - period: groupPeriod - }) - }) - .then(response => { - if (!response.ok) throw new Error("Failed to create group"); - return response.json(); - }) - .then(() => { - alert("Group created successfully!"); - $('#createGroupModal').modal('hide'); + errorElement.classList.add("d-none"); + errorElement.textContent = ""; + createButton.disabled = true; + + try { + const response = await fetch(javaURL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: groupName, + period: groupPeriod, + course: groupCourse, + memberIds: [] + }) + }); + + const responseBody = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(responseBody.error || `Failed to create group (HTTP ${response.status})`); + } + + bootstrap.Modal.getOrCreateInstance(document.getElementById("createGroupModal")).hide(); location.reload(); - }) - .catch(error => { + } catch (error) { console.error("Error creating group:", error); - alert("An error occurred. See console."); - }); + errorElement.textContent = error.message; + errorElement.classList.remove("d-none"); + createButton.disabled = false; + } }); // Open edit modal and pre-fill @@ -1015,4 +1030,4 @@ - \ No newline at end of file + diff --git a/src/test/java/com/open/spring/mvc/groups/ClassGroupMembershipServiceTest.java b/src/test/java/com/open/spring/mvc/groups/ClassGroupMembershipServiceTest.java new file mode 100644 index 00000000..6df401e4 --- /dev/null +++ b/src/test/java/com/open/spring/mvc/groups/ClassGroupMembershipServiceTest.java @@ -0,0 +1,151 @@ +package com.open.spring.mvc.groups; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.open.spring.mvc.person.Person; +import com.open.spring.mvc.person.PersonJpaRepository; + +class ClassGroupMembershipServiceTest { + private GroupsJpaRepository groupsRepository; + private PersonJpaRepository personRepository; + + private ClassGroupMembershipService service; + private Map> groupsByName; + private List> savedGroupBatches; + private Person person; + private Groups csa; + private Groups csp; + private Groups csh; + private Groups csse; + + @BeforeEach + void setUp() { + person = new Person(); + person.setUid("student"); + csa = group("CSA"); + csp = group("CSP"); + csh = group("CSH"); + csse = group("CSSE"); + + groupsByName = new HashMap<>(); + groupsByName.put("CSA", Optional.of(csa)); + groupsByName.put("CSP", Optional.of(csp)); + groupsByName.put("CSH", Optional.of(csh)); + groupsByName.put("CSSE", Optional.of(csse)); + savedGroupBatches = new ArrayList<>(); + + groupsRepository = repositoryStub( + GroupsJpaRepository.class, + (methodName, arguments) -> switch (methodName) { + case "findByName" -> groupsByName.get((String) arguments[0]); + case "saveAll" -> { + @SuppressWarnings("unchecked") + List groups = new ArrayList<>((List) arguments[0]); + savedGroupBatches.add(groups); + yield groups; + } + default -> throw new UnsupportedOperationException(methodName); + } + ); + personRepository = repositoryStub( + PersonJpaRepository.class, + (methodName, arguments) -> { + if ("findByUid".equals(methodName)) { + return "student".equals(arguments[0]) ? person : null; + } + throw new UnsupportedOperationException(methodName); + } + ); + service = new ClassGroupMembershipService(groupsRepository, personRepository); + } + + @Test + void syncMembershipsJoinsSelectedGroupsAndLeavesDeselectedCourseGroups() { + csh.addPerson(person); + Groups unrelatedGroup = group("Robotics"); + unrelatedGroup.addPerson(person); + + List memberships = service.syncMemberships("student", List.of("csa", "CSP")); + + assertEquals(List.of("CSA", "CSP"), memberships); + assertTrue(csa.getGroupMembers().contains(person)); + assertTrue(csp.getGroupMembers().contains(person)); + assertFalse(csh.getGroupMembers().contains(person)); + assertTrue(unrelatedGroup.getGroupMembers().contains(person)); + assertEquals(1, savedGroupBatches.size()); + assertEquals(List.of(csa, csp, csh), savedGroupBatches.get(0)); + } + + @Test + void syncMembershipsMapsEverySupportedClassToItsSameNamedGroup() { + for (String className : List.of("CSA", "CSP", "CSH", "CSSE")) { + service.syncMemberships("student", List.of(className)); + + for (Map.Entry> entry : groupsByName.entrySet()) { + assertEquals( + entry.getKey().equals(className), + entry.getValue().orElseThrow().getGroupMembers().contains(person) + ); + } + } + } + + @Test + void syncMembershipsRejectsUnsupportedClassesWithoutChangingGroups() { + assertThrows( + IllegalArgumentException.class, + () -> service.syncMemberships("student", List.of("Biology")) + ); + + assertFalse(csa.getGroupMembers().contains(person)); + assertTrue(savedGroupBatches.isEmpty()); + } + + @Test + void syncMembershipsFailsBeforeChangingMembershipWhenSelectedGroupIsMissing() { + csh.addPerson(person); + groupsByName.put("CSP", Optional.empty()); + + assertThrows( + NoSuchElementException.class, + () -> service.syncMemberships("student", List.of("CSP")) + ); + + assertTrue(csh.getGroupMembers().contains(person)); + assertTrue(savedGroupBatches.isEmpty()); + } + + private Groups group(String name) { + Groups group = new Groups(); + group.setName(name); + return group; + } + + @SuppressWarnings("unchecked") + private T repositoryStub(Class repositoryType, RepositoryCall call) { + return (T) Proxy.newProxyInstance( + repositoryType.getClassLoader(), + new Class[] {repositoryType}, + (proxy, method, arguments) -> call.invoke(method.getName(), arguments) + ); + } + + @FunctionalInterface + private interface RepositoryCall { + Object invoke(String methodName, Object[] arguments); + } +}