Skip to content
Open
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> syncMemberships(String uid, List<String> 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<String> requestedGroups = normalizeClasses(classes);
Map<String, Groups> courseGroups = loadCourseGroups();

for (String requestedGroup : requestedGroups) {
if (courseGroups.get(requestedGroup) == null) {
throw new NoSuchElementException(
"Course group '" + requestedGroup + "' was not found"
);
}
}

List<Groups> 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<String> normalizeClasses(List<String> classes) {
Set<String> 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<String, Groups> loadCourseGroups() {
Map<String, Groups> courseGroups = new LinkedHashMap<>();
for (String groupName : CLASS_GROUP_NAMES) {
courseGroups.put(groupName, groupsRepository.findByName(groupName).orElse(null));
}
return courseGroups;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,6 +41,9 @@ public class GroupsApiController {
@Autowired
private GroupChatService groupChatService;

@Autowired
private ClassGroupMembershipService classGroupMembershipService;

// ===== DTOs =====
@Data
@NoArgsConstructor
Expand Down Expand Up @@ -66,6 +71,13 @@ public static class BulkGroupCreateDto {
private List<GroupCreateDto> groups;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ClassMembershipSyncDto {
private List<String> classes;
}

// ===== Helper Methods =====
private Map<String, Object> buildGroupResponse(Groups group) {
Map<String, Object> groupMap = new LinkedHashMap<>();
Expand Down Expand Up @@ -306,6 +318,34 @@ public ResponseEntity<Map<String, Object>> 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<Map<String, Object>> syncClassMemberships(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody ClassMembershipSyncDto dto) {
if (userDetails == null) {
return new ResponseEntity<>(
Map.of("error", "Authentication is required"),
HttpStatus.UNAUTHORIZED
);
}

try {
List<String> 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" }
Expand Down Expand Up @@ -644,4 +684,4 @@ private void upsertGradeEntry(List<Map<String, Object>> grades, Map<String, Obje
}


}
}
20 changes: 20 additions & 0 deletions src/main/java/com/open/spring/system/ModelInit.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import com.open.spring.mvc.hardAssets.HardAssetsRepository;
import com.open.spring.mvc.jokes.Jokes;
import com.open.spring.mvc.jokes.JokesJpaRepository;
import com.open.spring.mvc.groups.Groups;
import com.open.spring.mvc.groups.GroupsJpaRepository;
import com.open.spring.mvc.media.MediaJpaRepository;
import com.open.spring.mvc.media.Score;
import com.open.spring.mvc.note.Note;
Expand Down Expand Up @@ -93,6 +95,7 @@ public class ModelInit {
@Autowired BankService bankService;

@Autowired MediaJpaRepository mediaJpaRepository;
@Autowired GroupsJpaRepository groupsJpaRepository;
@Autowired QuizScoreRepository quizScoreRepository;
@Autowired ResumeJpaRepository resumeJpaRepository;
@Autowired StatsRepository statsRepository; // curators - stats
Expand Down Expand Up @@ -202,6 +205,23 @@ CommandLineRunner run() {
noteRepo.save(n);
}
}

String[][] defaultGroups = {
{"CSA", "CSA", "2"},
{"CSP", "CSP", "3"},
{"CSH", "CSH", "2"},
{"CSSE", "CSSE", "1"}
};
for (String[] defaultGroup : defaultGroups) {
String groupName = defaultGroup[0];
if (groupsJpaRepository.findByName(groupName).isEmpty()) {
Groups group = new Groups();
group.setName(groupName);
group.setCourse(defaultGroup[1]);
group.setPeriod(defaultGroup[2]);
groupsJpaRepository.save(group);
}
}

List<Announcement> announcements = Announcement.init();
for (Announcement announcement : announcements) {
Expand Down
99 changes: 57 additions & 42 deletions src/main/resources/templates/group/group.html
Original file line number Diff line number Diff line change
Expand Up @@ -152,25 +152,33 @@ <h5 class="modal-title" id="createGroupModalLabel">Create Group</h5>
</div>


<div class="modal-body">
<div class="mb-3">
<label for="groupNameInput" class="form-label">Group Name</label>
<input type="text" class="form-control" id="groupNameInput" placeholder="Enter group name">
</div>
<form id="createGroupForm">
<div class="modal-body">
<div class="mb-3">
<label for="groupNameInput" class="form-label">Group Name</label>
<input type="text" class="form-control" id="groupNameInput" placeholder="Enter group name" required>
</div>


<div class="mb-3">
<label for="groupPeriodInput" class="form-label">Group Period</label>
<input type="text" class="form-control" id="groupPeriodInput" placeholder="Enter group period">
</div>
<div class="mb-3">
<label for="groupPeriodInput" class="form-label">Group Period</label>
<input type="text" class="form-control" id="groupPeriodInput" placeholder="Enter group period">
</div>

</div>
<div class="mb-3">
<label for="groupCourseInput" class="form-label">Course</label>
<input type="text" class="form-control" id="groupCourseInput" placeholder="Enter course">
</div>

<div id="createGroupError" class="alert alert-danger d-none" role="alert"></div>
</div>

<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" id="createGroupBtn">Create Group</button>
</div>

<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-secondary" id="createGroupBtn">Create Group</button>
</div>
</form>
</div>
</div>
</div>
Expand Down Expand Up @@ -502,37 +510,44 @@ <h5 class="modal-title" id="importModalLabel">Import Groups</h5>
});

// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a modal API supported by Bootstrap 5.0.2

When group creation succeeds, this page's Bootstrap 5.0.2 bundle does not provide Modal.getOrCreateInstance, so this line throws before location.reload(). The catch block then reports an error even though the server already created the group, and retrying produces a duplicate-name conflict; use the existing 5.0-compatible modal instance API or reload directly.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -1015,4 +1030,4 @@ <h5 class="modal-title" id="importModalLabel">Import Groups</h5>
</th:block>


</html>
</html>
Loading