From 4d909d2cf54f47a6391b784cc7b7781e372e9ced Mon Sep 17 00:00:00 2001 From: adikatre Date: Fri, 20 Mar 2026 10:23:05 -0700 Subject: [PATCH 01/15] permitall on ws-chat endpoint (websocket doesn't have http headers) --- src/main/java/com/open/spring/security/MvcSecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/open/spring/security/MvcSecurityConfig.java b/src/main/java/com/open/spring/security/MvcSecurityConfig.java index c099e593..167f9296 100644 --- a/src/main/java/com/open/spring/security/MvcSecurityConfig.java +++ b/src/main/java/com/open/spring/security/MvcSecurityConfig.java @@ -106,7 +106,7 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers("/mvc/assignments/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/mvc/bank/read").hasAuthority("ROLE_ADMIN") .requestMatchers("/mvc/progress/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") - .requestMatchers("/ws-chat/**").authenticated() + .requestMatchers("/ws-chat/**").permitAll() .requestMatchers("/run/**").permitAll() // Java runner endpoints - public access .anyRequest().authenticated() ) From 5d4cd334183df3087492939e6a8090a651d2042c Mon Sep 17 00:00:00 2001 From: adikatre Date: Fri, 20 Mar 2026 11:16:46 -0700 Subject: [PATCH 02/15] Revert "permitall on ws-chat endpoint (websocket doesn't have http headers)" This reverts commit 4d909d2cf54f47a6391b784cc7b7781e372e9ced. --- src/main/java/com/open/spring/security/MvcSecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/open/spring/security/MvcSecurityConfig.java b/src/main/java/com/open/spring/security/MvcSecurityConfig.java index 167f9296..c099e593 100644 --- a/src/main/java/com/open/spring/security/MvcSecurityConfig.java +++ b/src/main/java/com/open/spring/security/MvcSecurityConfig.java @@ -106,7 +106,7 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers("/mvc/assignments/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/mvc/bank/read").hasAuthority("ROLE_ADMIN") .requestMatchers("/mvc/progress/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") - .requestMatchers("/ws-chat/**").permitAll() + .requestMatchers("/ws-chat/**").authenticated() .requestMatchers("/run/**").permitAll() // Java runner endpoints - public access .anyRequest().authenticated() ) From ac496b113ca2017b0bfd902f6df81db348865322 Mon Sep 17 00:00:00 2001 From: Rbojja23 Date: Sun, 22 Mar 2026 11:21:29 -0700 Subject: [PATCH 03/15] Updating Bathroom Queue Existing Controllers --- .../spring/mvc/bathroom/BathroomQueue.java | 40 +++--- .../bathroom/BathroomQueueApiController.java | 126 ++++++++++++------ 2 files changed, 107 insertions(+), 59 deletions(-) diff --git a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java index 702eb8c5..a431f566 100644 --- a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java +++ b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java @@ -27,6 +27,9 @@ public class BathroomQueue { private String peopleQueue; private int away; + @Column(columnDefinition = "int default 1") + private int maxOccupancy = 1; + // Custom constructor /** @@ -61,14 +64,23 @@ public void addStudent(String studentName) { * your own name is passed. */ public void removeStudent(String studentName) { - if (this.peopleQueue != null) { - this.peopleQueue = Arrays.stream(this.peopleQueue.split(",")) + if (this.peopleQueue != null && !this.peopleQueue.isEmpty()) { + String[] studentsBefore = this.peopleQueue.split(","); + this.peopleQueue = Arrays.stream(studentsBefore) .filter(s -> !s.equals(studentName)) .collect(Collectors.joining(",")); + String[] studentsAfter = this.peopleQueue.isEmpty() ? new String[0] : this.peopleQueue.split(","); + + // If a student was actually removed, and they were part of the 'active' count + // (or simply someone leaving) + if (studentsBefore.length > studentsAfter.length) { + if (this.away > 0) { + this.away--; + } + } } } - /** * @return - returns the student who is at the front of the line, removing the * commas and sanitizing the data @@ -86,19 +98,17 @@ public String getFrontStudent() { * When they return, they are removed from the queue */ public void approveStudent() { - if (this.peopleQueue != null && !this.peopleQueue.isEmpty()) { - if (this.away == 0) { - // Student is approved to go away - this.away = 1; - } else { - // Student has returned; remove from queue - String[] students = this.peopleQueue.split(","); - if (students.length > 1) { - this.peopleQueue = String.join(",", Arrays.copyOfRange(students, 1, students.length)); - } else { - this.peopleQueue = ""; + if (this.peopleQueue != null && !this.peopleQueue.isEmpty()) { + if (this.away < this.maxOccupancy) { + // Determine how many people are actually in the queue + int totalInQueue = this.peopleQueue.split(",").length; + // We can't have more people 'away' than are in the queue + if (this.away < totalInQueue) { + this.away++; } - this.away = 0; + } else { + // If already at max occupancy, we don't increment away. + // The frontend should handle showing they are in the waiting list. } } else { throw new IllegalStateException("Queue is empty"); diff --git a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java index 30f6a708..075cece2 100644 --- a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java +++ b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java @@ -24,13 +24,15 @@ import lombok.Getter; /** - * This class provides RESTful API endpoints for managing BathroomQueue entities. - * It includes endpoints for creating, retrieving, updating, and managing bathroom + * This class provides RESTful API endpoints for managing BathroomQueue + * entities. + * It includes endpoints for creating, retrieving, updating, and managing + * bathroom * queue operations for classroom management. */ @RestController @RequestMapping("/api/queue") // Base URL for all endpoints in this controller -@CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com/"}) +@CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com/" }) public class BathroomQueueApiController { /** @@ -38,16 +40,17 @@ public class BathroomQueueApiController { */ @Autowired private BathroomQueueJPARepository repository; - + /** - * DTO (Data Transfer Object) to support request operations for queue management. + * DTO (Data Transfer Object) to support request operations for queue + * management. * Contains necessary information for student queue operations. */ @Getter public static class QueueDto { private String teacherEmail; // Teacher's email associated with the queue - private String studentName; // Name of the student to be added/removed/approved - private String uri; // URI for constructing approval links + private String studentName; // Name of the student to be added/removed/approved + private String uri; // URI for constructing approval links } /** @@ -57,17 +60,20 @@ public static class QueueDto { @Getter public static class QueueAddReq { private String teacherEmail; // Teacher's email to associate with the new queue - private String peopleQueue; // Initial student(s) to add to the queue + private String peopleQueue; // Initial student(s) to add to the queue } - + /** * Create a new BathroomQueue entity for a teacher. * - * @param request The QueueAddReq object containing teacher email and initial queue data - * @return A ResponseEntity containing a success message if the queue is created, - * or a CONFLICT status if queue already exists, or INTERNAL_SERVER_ERROR if creation fails + * @param request The QueueAddReq object containing teacher email and initial + * queue data + * @return A ResponseEntity containing a success message if the queue is + * created, + * or a CONFLICT status if queue already exists, or + * INTERNAL_SERVER_ERROR if creation fails */ - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) @PostMapping("/addQueue") public ResponseEntity addQueue(@RequestBody QueueAddReq request) { System.out.println(request); @@ -84,18 +90,21 @@ public ResponseEntity addQueue(@RequestBody QueueAddReq request) { repository.save(newQueue); return ResponseEntity.ok("Queue added successfully!"); } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to add queue: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to add queue: " + e.getMessage()); } } /** - * Add a student to an existing bathroom queue or create a new queue if none exists. + * Add a student to an existing bathroom queue or create a new queue if none + * exists. * * @param queueDto The QueueDto object containing teacher email and student name - * @return A ResponseEntity containing a success message with student and teacher information, + * @return A ResponseEntity containing a success message with student and + * teacher information, * or a CREATED status if operation is successful */ - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) @PostMapping("/add") public ResponseEntity addToQueue(@RequestBody QueueDto queueDto) { // Check if a queue already exists for the given teacher @@ -109,24 +118,26 @@ public ResponseEntity addToQueue(@RequestBody QueueDto queueDto) { BathroomQueue newQueue = new BathroomQueue(queueDto.getTeacherEmail(), queueDto.getStudentName()); repository.save(newQueue); // Save the new queue to the database } - return new ResponseEntity<>(queueDto.getStudentName() + " was added to " + queueDto.getTeacherEmail(), HttpStatus.CREATED); + return new ResponseEntity<>(queueDto.getStudentName() + " was added to " + queueDto.getTeacherEmail(), + HttpStatus.CREATED); } /** * Remove a specific student from a teacher's bathroom queue. * - * @param queueDto The QueueDto object containing teacher email and student name to remove + * @param queueDto The QueueDto object containing teacher email and student name + * to remove * @return A ResponseEntity containing a success message if student is removed, * or a NOT_FOUND status if queue or student is not found */ - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) @PostMapping("/remove") public ResponseEntity removeFromQueue(@RequestBody QueueDto queueDto) { Optional queueEntry = repository.findByTeacherEmail(queueDto.getTeacherEmail()); - + if (queueEntry.isPresent()) { BathroomQueue bathroomQueue = queueEntry.get(); - + try { // Remove the student from the queue bathroomQueue.removeStudent(queueDto.getStudentName()); @@ -136,17 +147,19 @@ public ResponseEntity removeFromQueue(@RequestBody QueueDto queueDto) { return new ResponseEntity<>(e.getMessage(), HttpStatus.NOT_FOUND); } } - + return new ResponseEntity<>("Queue for " + queueDto.getTeacherEmail() + " not found", HttpStatus.NOT_FOUND); } - + /** * Remove the first student from a teacher's bathroom queue. * - * @param teacher The teacher's email whose queue's front student should be removed - * @return void - This method does not return a ResponseEntity (consider adding one for better API design) + * @param teacher The teacher's email whose queue's front student should be + * removed + * @return void - This method does not return a ResponseEntity (consider adding + * one for better API design) */ - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) @PostMapping("/removefront/{teacher}") public void removeFront(@PathVariable String teacher) { Optional queueEntry = repository.findByTeacherEmail(teacher); @@ -160,31 +173,53 @@ public void removeFront(@PathVariable String teacher) { * Approve the first student in a teacher's bathroom queue. * Only the student at the front of the queue can be approved. * - * @param queueDto The QueueDto object containing teacher email and student name to approve + * @param queueDto The QueueDto object containing teacher email and student name + * to approve * @return A ResponseEntity containing a success message if student is approved, - * BAD_REQUEST if student is not at front of queue, or NOT_FOUND if queue doesn't exist + * BAD_REQUEST if student is not at front of queue, or NOT_FOUND if + * queue doesn't exist */ - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "*" }) @PostMapping("/approve") public ResponseEntity approveStudent(@RequestBody QueueDto queueDto) { Optional queueEntry = repository.findByTeacherEmail(queueDto.getTeacherEmail()); if (queueEntry.isPresent()) { BathroomQueue bathroomQueue = queueEntry.get(); - String frontStudent = bathroomQueue.getFrontStudent(); - - if (frontStudent != null && frontStudent.equals(queueDto.getStudentName())) { + try { bathroomQueue.approveStudent(); repository.save(bathroomQueue); - return new ResponseEntity<>("Approved " + queueDto.getStudentName(), HttpStatus.OK); + return new ResponseEntity<>("Approved student for " + queueDto.getTeacherEmail(), HttpStatus.OK); + } catch (IllegalStateException e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); } - return new ResponseEntity<>("Student is not at the front of the queue", HttpStatus.BAD_REQUEST); } return new ResponseEntity<>("Queue for " + queueDto.getTeacherEmail() + " not found", HttpStatus.NOT_FOUND); } - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "*" }) + @PostMapping("/updateMaxOccupancy") + public ResponseEntity updateMaxOccupancy(@RequestBody Map payload) { + String teacherEmail = (String) payload.get("teacherEmail"); + Integer maxOccupancy = (Integer) payload.get("maxOccupancy"); + + if (teacherEmail == null || maxOccupancy == null) { + return new ResponseEntity<>("teacherEmail and maxOccupancy are required", HttpStatus.BAD_REQUEST); + } + + Optional queueEntry = repository.findByTeacherEmail(teacherEmail); + if (queueEntry.isPresent()) { + BathroomQueue queue = queueEntry.get(); + queue.setMaxOccupancy(maxOccupancy); + repository.save(queue); + return new ResponseEntity<>("Updated maxOccupancy to " + maxOccupancy, HttpStatus.OK); + } + + return new ResponseEntity<>("Queue for " + teacherEmail + " not found", HttpStatus.NOT_FOUND); + } + + @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) @PostMapping("/removeFront") public ResponseEntity removeFrontStudent(@RequestBody QueueDto queueDto) { Optional queueEntry = repository.findByTeacherEmail(queueDto.getTeacherEmail()); @@ -216,15 +251,18 @@ public ResponseEntity removeFrontStudent(@RequestBody QueueDto queueDto) /** * Approve a student via a direct link with query parameters. - * This endpoint allows teachers to approve students through email links or direct URLs. + * This endpoint allows teachers to approve students through email links or + * direct URLs. * * @param teacherEmail The teacher's email associated with the queue - * @param studentName The name of the student to approve + * @param studentName The name of the student to approve * @return A ResponseEntity containing a success message if student is approved, - * BAD_REQUEST if student is not at front of queue, or NOT_FOUND if queue doesn't exist + * BAD_REQUEST if student is not at front of queue, or NOT_FOUND if + * queue doesn't exist */ @GetMapping("/approveLink") - public ResponseEntity approveStudentViaLink(@RequestParam String teacherEmail, @RequestParam String studentName) { + public ResponseEntity approveStudentViaLink(@RequestParam String teacherEmail, + @RequestParam String studentName) { Optional queueEntry = repository.findByTeacherEmail(teacherEmail); if (queueEntry.isPresent()) { BathroomQueue bathroomQueue = queueEntry.get(); @@ -252,11 +290,12 @@ public ResponseEntity> getAllQueues() { /** * Retrieves all active bathroom queues. - * Currently returns all queues - consider implementing filtering for truly "active" queues. + * Currently returns all queues - consider implementing filtering for truly + * "active" queues. * * @return A ResponseEntity containing a list of all BathroomQueue entities */ - @CrossOrigin(origins = {"http://localhost:8585", "https://pages.opencodingsociety.com"}) + @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) @GetMapping("/getActive") public ResponseEntity getActiveQueues() { return new ResponseEntity<>(repository.findAll(), HttpStatus.OK); @@ -273,7 +312,6 @@ public ResponseEntity clearTable(@RequestParam(required = false) String role) return ResponseEntity.ok(Map.of( "status", "success", - "message", "All bathroom queue records have been cleared" - )); + "message", "All bathroom queue records have been cleared")); } } \ No newline at end of file From 23c0be289184db798166dc62892b735aed60bd00 Mon Sep 17 00:00:00 2001 From: Rbojja23 Date: Sun, 22 Mar 2026 11:22:18 -0700 Subject: [PATCH 04/15] Updating Person Controllers in spring to allow for face data --- .../com/open/spring/mvc/person/Person.java | 355 +++++++++--------- .../mvc/person/PersonApiController.java | 271 +++++++------ 2 files changed, 320 insertions(+), 306 deletions(-) diff --git a/src/main/java/com/open/spring/mvc/person/Person.java b/src/main/java/com/open/spring/mvc/person/Person.java index 1b526e42..ae7a5255 100644 --- a/src/main/java/com/open/spring/mvc/person/Person.java +++ b/src/main/java/com/open/spring/mvc/person/Person.java @@ -47,7 +47,6 @@ import lombok.NoArgsConstructor; import lombok.NonNull; - /** * Person is a POJO, Plain Old Java Object. * --- @Data is Lombox annotation @@ -63,12 +62,13 @@ @AllArgsConstructor @NoArgsConstructor @Entity -@JsonIgnoreProperties({"submissions", "groups"}) +@JsonIgnoreProperties({ "submissions", "groups" }) public class Person extends Submitter implements Comparable { -////////////////////////////////////////////////////////////////////////////////// -/// Columns stored on Person - /** Automatic unique identifier for Person or group record + ////////////////////////////////////////////////////////////////////////////////// + /// Columns stored on Person + /** + * Automatic unique identifier for Person or group record * --- Id annotation is used to specify the identifier property of the entity. * ----GeneratedValue annotation is used to specify the primary key generation * strategy to use. @@ -103,7 +103,6 @@ public class Person extends Submitter implements Comparable { @Email private String email; - @Column(unique = true, nullable = false) private String uid; // New `uid` column added @@ -120,20 +119,20 @@ public class Person extends Submitter implements Comparable { @Size(min = 2, max = 30, message = "Name (2 to 30 chars)") private String name; - - - /** Profile picture (pfp) in base64 */ @Column(length = 255, nullable = true) private String pfp; - @Column(nullable = false, columnDefinition = "boolean default false") private Boolean kasmServerNeeded = false; - @Column(nullable=true) + @Column(nullable = true) private String sid; + /** Facial data for recognition (base64 or embedding) */ + @Column(columnDefinition = "text") + private String faceData; + /** * stats is used to store JSON for daily stats * --- @JdbcTypeCode annotation is used to specify the JDBC type code for a @@ -154,23 +153,21 @@ public class Person extends Submitter implements Comparable { /** * gradesJson stores this person's grades as a JSON blob in a TEXT column. - * Example entry: {"id":12345, "assignment":"hw1", "score":95.0, "course":"CS101", "submission":"..."} - * Persisted as TEXT to support SQLite; conversion handled by GradesJsonConverter. + * Example entry: {"id":12345, "assignment":"hw1", "score":95.0, + * "course":"CS101", "submission":"..."} + * Persisted as TEXT to support SQLite; conversion handled by + * GradesJsonConverter. */ @Convert(converter = GradesJsonConverter.class) @Column(name = "gradesJson", columnDefinition = "text") private List> gradesJson = new ArrayList<>(); + ////////////////////////////////////////////////////////////////////////////////// + /// Relationships -////////////////////////////////////////////////////////////////////////////////// -/// Relationships - - - @OneToMany(mappedBy="student", cascade=CascadeType.ALL, orphanRemoval=true) + @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List grades; - - /** * Many to Many relationship with PersonRole @@ -187,41 +184,41 @@ public class Person extends Submitter implements Comparable { @ManyToMany(fetch = FetchType.EAGER) private Collection roles = new ArrayList<>(); - - @OneToOne(mappedBy = "person", cascade=CascadeType.ALL) + @OneToOne(mappedBy = "person", cascade = CascadeType.ALL) @JsonIgnore private Tinkle timeEntries; @OneToOne(cascade = CascadeType.ALL, mappedBy = "person") private Bank banks; - @OneToOne(cascade = CascadeType.ALL, mappedBy = "person") @JsonIgnore private userStocksTable user_stocks; - @ManyToMany(mappedBy = "groupMembers") @JsonBackReference @JsonIgnore private List groups = new ArrayList<>(); - @OneToOne(mappedBy = "owner", cascade = CascadeType.ALL) + @OneToOne(mappedBy = "owner", cascade = CascadeType.ALL) @PrimaryKeyJoinColumn @JsonIgnore private TrainCompany company; -////////////////////////////////////////////////////////////////////////////////// -/// Constructors - + ////////////////////////////////////////////////////////////////////////////////// + /// Constructors - /** Custom constructor for Person when building a new Person object from an API call - * @param email, a String + /** + * Custom constructor for Person when building a new Person object from an API + * call + * + * @param email, a String * @param password, a String - * @param name, a String - * @param dob, a Date + * @param name, a String + * @param dob, a Date */ - public Person(String email, String uid, String password, String sid, String name, String pfp, Boolean kasmServerNeeded, PersonRole role) { + public Person(String email, String uid, String password, String sid, String name, String pfp, + Boolean kasmServerNeeded, PersonRole role) { this.email = email; this.uid = uid; this.password = password; @@ -231,31 +228,34 @@ public Person(String email, String uid, String password, String sid, String name this.pfp = pfp; this.roles.add(role); - this.timeEntries = new Tinkle(this, ""); + this.timeEntries = new Tinkle(this, ""); // Create a Bank for this person this.banks = new Bank(this); } - - /** 1st telescoping method to create a Person object with USER role + /** + * 1st telescoping method to create a Person object with USER role + * * @param name * @param email * @param password * @param dob * @return Person */ - public static Person createPerson(String name, String email, String uid, String password, String sid, Boolean kasmServerNeeded, List asList) { + public static Person createPerson(String name, String email, String uid, String password, String sid, + Boolean kasmServerNeeded, List asList) { // By default, Spring Security expects roles to have a "ROLE_" prefix. - return createPerson(name, email, uid, password, sid, kasmServerNeeded, Arrays.asList("ROLE_USER", "ROLE_STUDENT")); + return createPerson(name, email, uid, password, sid, kasmServerNeeded, + Arrays.asList("ROLE_USER", "ROLE_STUDENT")); } - /** * 2nd telescoping method to create a Person object with parameterized roles * * @param roles */ - public static Person createPerson(String name, String uid, String email, String password, String sid, String pfp, Boolean kasmServerNeeded, List roleNames) { + public static Person createPerson(String name, String uid, String email, String password, String sid, String pfp, + Boolean kasmServerNeeded, List roleNames) { Person person = new Person(); person.setName(name); person.setUid(uid); @@ -275,21 +275,21 @@ public static Person createPerson(String name, String uid, String email, String return person; } + ////////////////////////////////////////////////////////////////////////////////// + /// getter methods -////////////////////////////////////////////////////////////////////////////////// -/// getter methods - - - /** Custom getter to return age from dob attribute + /** + * Custom getter to return age from dob attribute + * * @return int, the age of the person - */ - + */ + ////////////////////////////////////////////////////////////////////////////////// + // other methods -////////////////////////////////////////////////////////////////////////////////// - // other methods - - /** Custom hasRoleWithName method to find if a role exists on user + /** + * Custom hasRoleWithName method to find if a role exists on user + * * @param roleName, a String with the name of the role * @return boolean, the result of the search */ @@ -302,8 +302,9 @@ public boolean hasRoleWithName(String roleName) { return false; } - - /** Custom compareTo method to compare Person objects by name + /** + * Custom compareTo method to compare Person objects by name + * * @param other, a Person object * @return int, the result of the comparison */ @@ -312,15 +313,15 @@ public int compareTo(Person other) { return this.name.compareTo(other.name); } - -////////////////////////////////////////////////////////////////////////////////// -/// initalization method - + ////////////////////////////////////////////////////////////////////////////////// + /// initalization method /** * Static method to initialize an array list of Person objects * Uses createPerson method to create Person objects - * Sorts the list of Person objects using Collections.sort which uses the compareTo method + * Sorts the list of Person objects using Collections.sort which uses the + * compareTo method + * * @return Person[], an array of Person objects */ public static Person[] init() { @@ -328,127 +329,116 @@ public static Person[] init() { final Dotenv dotenv = Dotenv.load(); String defaultPassword = envOrDefault(dotenv, "DEFAULT_PASSWORD", "defaultPassword123"); - + // JSON-like list of person data using Map.ofEntries List> personData = Arrays.asList( - // Admin user from .env - Map.ofEntries( - Map.entry("name", envOrDefault(dotenv, "ADMIN_NAME", "Admin User")), - Map.entry("uid", envOrDefault(dotenv, "ADMIN_UID", "admin")), - Map.entry("email", envOrDefault(dotenv, "ADMIN_EMAIL", "admin@example.com")), - Map.entry("password", envOrDefault(dotenv, "ADMIN_PASSWORD", defaultPassword)), - Map.entry("sid", envOrDefault(dotenv, "ADMIN_SID", "9999990")), - Map.entry("pfp", envOrDefault(dotenv, "ADMIN_PFP", "/images/default.png")), - Map.entry("kasmServerNeeded", false), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN")), - Map.entry("stocks", "BTC,ETH") - ), - // Teacher user from .env - Map.ofEntries( - Map.entry("name", envOrDefault(dotenv, "TEACHER_NAME", "Teacher User")), - Map.entry("uid", envOrDefault(dotenv, "TEACHER_UID", "teacher")), - Map.entry("email", envOrDefault(dotenv, "TEACHER_EMAIL", "teacher@example.com")), - Map.entry("password", envOrDefault(dotenv, "TEACHER_PASSWORD", defaultPassword)), - Map.entry("sid", envOrDefault(dotenv, "TEACHER_SID", "9999998")), - Map.entry("pfp", envOrDefault(dotenv, "TEACHER_PFP", "/images/default.png")), - Map.entry("kasmServerNeeded", true), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_TEACHER")), - Map.entry("stocks", "BTC,ETH") - ), - // Default user from .env - Map.ofEntries( - Map.entry("name", envOrDefault(dotenv, "USER_NAME", "Default User")), - Map.entry("uid", envOrDefault(dotenv, "USER_UID", "user")), - Map.entry("email", envOrDefault(dotenv, "USER_EMAIL", "user@example.com")), - Map.entry("password", envOrDefault(dotenv, "USER_PASSWORD", defaultPassword)), - Map.entry("sid", envOrDefault(dotenv, "USER_SID", "9999999")), - Map.entry("pfp", envOrDefault(dotenv, "USER_PFP", "/images/default.png")), - Map.entry("kasmServerNeeded", true), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), - Map.entry("stocks", "BTC,ETH") - ), - // Alexander Graham Bell - hardcoded student user - Map.ofEntries( - Map.entry("name", "Alexander Graham Bell"), - Map.entry("uid", "lex"), - Map.entry("email", "lexb@gmail.com"), - Map.entry("password", defaultPassword), - Map.entry("sid", "9999991"), - Map.entry("pfp", "/images/lex.png"), - Map.entry("kasmServerNeeded", false), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), - Map.entry("stocks", "BTC,ETH") - ), - // Madam Curie - hardcoded student user - Map.ofEntries( - Map.entry("name", "Madam Curie"), - Map.entry("uid", "madam"), - Map.entry("email", "madam@gmail.com"), - Map.entry("password", defaultPassword), - Map.entry("sid", "9999992"), - Map.entry("pfp", "/images/madam.png"), - Map.entry("kasmServerNeeded", false), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), - Map.entry("stocks", "BTC,ETH") - ), - // My user - from .env - Map.ofEntries( - Map.entry("name", envOrDefault(dotenv, "MY_NAME", "My User")), - Map.entry("uid", envOrDefault(dotenv, "MY_UID", "myuser")), - Map.entry("email", envOrDefault(dotenv, "MY_EMAIL", "myuser@example.com")), - Map.entry("password", defaultPassword), - Map.entry("sid", envOrDefault(dotenv, "MY_SID", "9999993")), - Map.entry("pfp", "/images/default.png"), - Map.entry("kasmServerNeeded", true), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN")), - Map.entry("stocks", "BTC,ETH") - ), - // Alan Turing - hardcoded student user - Map.ofEntries( - Map.entry("name", "Alan Turing"), - Map.entry("uid", "alan"), - Map.entry("email", "turing@gmail.com"), - Map.entry("password", defaultPassword), - Map.entry("sid", "9999994"), - Map.entry("pfp", "/images/alan.png"), - Map.entry("kasmServerNeeded", false), - Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), - Map.entry("stocks", "BTC,ETH") - ) - ); + // Admin user from .env + Map.ofEntries( + Map.entry("name", envOrDefault(dotenv, "ADMIN_NAME", "Admin User")), + Map.entry("uid", envOrDefault(dotenv, "ADMIN_UID", "admin")), + Map.entry("email", envOrDefault(dotenv, "ADMIN_EMAIL", "admin@example.com")), + Map.entry("password", envOrDefault(dotenv, "ADMIN_PASSWORD", defaultPassword)), + Map.entry("sid", envOrDefault(dotenv, "ADMIN_SID", "9999990")), + Map.entry("pfp", envOrDefault(dotenv, "ADMIN_PFP", "/images/default.png")), + Map.entry("kasmServerNeeded", false), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN")), + Map.entry("stocks", "BTC,ETH")), + // Teacher user from .env + Map.ofEntries( + Map.entry("name", envOrDefault(dotenv, "TEACHER_NAME", "Teacher User")), + Map.entry("uid", envOrDefault(dotenv, "TEACHER_UID", "teacher")), + Map.entry("email", envOrDefault(dotenv, "TEACHER_EMAIL", "teacher@example.com")), + Map.entry("password", envOrDefault(dotenv, "TEACHER_PASSWORD", defaultPassword)), + Map.entry("sid", envOrDefault(dotenv, "TEACHER_SID", "9999998")), + Map.entry("pfp", envOrDefault(dotenv, "TEACHER_PFP", "/images/default.png")), + Map.entry("kasmServerNeeded", true), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_TEACHER")), + Map.entry("stocks", "BTC,ETH")), + // Default user from .env + Map.ofEntries( + Map.entry("name", envOrDefault(dotenv, "USER_NAME", "Default User")), + Map.entry("uid", envOrDefault(dotenv, "USER_UID", "user")), + Map.entry("email", envOrDefault(dotenv, "USER_EMAIL", "user@example.com")), + Map.entry("password", envOrDefault(dotenv, "USER_PASSWORD", defaultPassword)), + Map.entry("sid", envOrDefault(dotenv, "USER_SID", "9999999")), + Map.entry("pfp", envOrDefault(dotenv, "USER_PFP", "/images/default.png")), + Map.entry("kasmServerNeeded", true), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), + Map.entry("stocks", "BTC,ETH")), + // Alexander Graham Bell - hardcoded student user + Map.ofEntries( + Map.entry("name", "Alexander Graham Bell"), + Map.entry("uid", "lex"), + Map.entry("email", "lexb@gmail.com"), + Map.entry("password", defaultPassword), + Map.entry("sid", "9999991"), + Map.entry("pfp", "/images/lex.png"), + Map.entry("kasmServerNeeded", false), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), + Map.entry("stocks", "BTC,ETH")), + // Madam Curie - hardcoded student user + Map.ofEntries( + Map.entry("name", "Madam Curie"), + Map.entry("uid", "madam"), + Map.entry("email", "madam@gmail.com"), + Map.entry("password", defaultPassword), + Map.entry("sid", "9999992"), + Map.entry("pfp", "/images/madam.png"), + Map.entry("kasmServerNeeded", false), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), + Map.entry("stocks", "BTC,ETH")), + // My user - from .env + Map.ofEntries( + Map.entry("name", envOrDefault(dotenv, "MY_NAME", "My User")), + Map.entry("uid", envOrDefault(dotenv, "MY_UID", "myuser")), + Map.entry("email", envOrDefault(dotenv, "MY_EMAIL", "myuser@example.com")), + Map.entry("password", defaultPassword), + Map.entry("sid", envOrDefault(dotenv, "MY_SID", "9999993")), + Map.entry("pfp", "/images/default.png"), + Map.entry("kasmServerNeeded", true), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN")), + Map.entry("stocks", "BTC,ETH")), + // Alan Turing - hardcoded student user + Map.ofEntries( + Map.entry("name", "Alan Turing"), + Map.entry("uid", "alan"), + Map.entry("email", "turing@gmail.com"), + Map.entry("password", defaultPassword), + Map.entry("sid", "9999994"), + Map.entry("pfp", "/images/alan.png"), + Map.entry("kasmServerNeeded", false), + Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), + Map.entry("stocks", "BTC,ETH"))); // Iterate over the JSON-like list to create Person objects for (Map data : personData) { Person person = createPerson( - (String) data.get("name"), - (String) data.get("uid"), - (String) data.get("email"), - (String) data.get("password"), - (String) data.get("sid"), - (String) data.get("pfp"), - (Boolean) data.get("kasmServerNeeded"), - (List) data.get("roles") - ); - - + (String) data.get("name"), + (String) data.get("uid"), + (String) data.get("email"), + (String) data.get("password"), + (String) data.get("sid"), + (String) data.get("pfp"), + (Boolean) data.get("kasmServerNeeded"), + (List) data.get("roles")); + // Create userStocksTable and set the one-to-one relationship userStocksTable stock = new userStocksTable( - null, - (String) data.get("stocks"), - person.getEmail(), - person, - false, - true, - "" - ); + null, + (String) data.get("stocks"), + person.getEmail(), + person, + false, + true, + ""); stock.setPerson(person); // Set the one-to-one relationship person.setUser_stocks(stock); - + people.add(person); } - + // Sort the list of people Collections.sort(people); - + return people.toArray(new Person[0]); } @@ -460,31 +450,28 @@ private static String envOrDefault(Dotenv dotenv, String key, String defaultValu return value; } - -////////////////////////////////////////////////////////////////////////////////// -/// override toString() method - + ////////////////////////////////////////////////////////////////////////////////// + /// override toString() method @Override - public String toString(){ + public String toString() { String output = "person : {"; - output += "\"id\":"+ String.valueOf(this.getId())+","; //id - output += "\"uid\":\""+ String.valueOf(this.getUid())+"\","; //user id (github/email) - output += "\"email\":\""+ String.valueOf(this.getEmail())+"\","; //email - // output += "\"password\":\""+ String.valueOf(this.getPassword())+"\","; //password - output += "\"name\":\""+ String.valueOf(this.getName())+"\","; // name - output += "\"sid\":\""+ String.valueOf(this.getSid())+"\","; // student id - output += "\"kasmServerNeeded\":\""+ String.valueOf(this.getKasmServerNeeded())+"\","; // kasm server needed - output += "\"stats\":"+ String.valueOf(this.getStats())+","; //stats (I think this is unused) + output += "\"id\":" + String.valueOf(this.getId()) + ","; // id + output += "\"uid\":\"" + String.valueOf(this.getUid()) + "\","; // user id (github/email) + output += "\"email\":\"" + String.valueOf(this.getEmail()) + "\","; // email + // output += "\"password\":\""+ String.valueOf(this.getPassword())+"\","; + // //password + output += "\"name\":\"" + String.valueOf(this.getName()) + "\","; // name + output += "\"sid\":\"" + String.valueOf(this.getSid()) + "\","; // student id + output += "\"kasmServerNeeded\":\"" + String.valueOf(this.getKasmServerNeeded()) + "\","; // kasm server needed + output += "\"stats\":" + String.valueOf(this.getStats()) + ","; // stats (I think this is unused) output += "}"; return output; } - -////////////////////////////////////////////////////////////////////////////////// -/// public static void main(String[] args){} - + ////////////////////////////////////////////////////////////////////////////////// + /// public static void main(String[] args){} /** * Static method to print Person objects from an array @@ -497,7 +484,7 @@ public static void main(String[] args) { // iterate using "enhanced for loop" for (Person person : persons) { - System.out.println(person); // print object + System.out.println(person); // print object System.out.println(); } } diff --git a/src/main/java/com/open/spring/mvc/person/PersonApiController.java b/src/main/java/com/open/spring/mvc/person/PersonApiController.java index 13458769..403be60d 100644 --- a/src/main/java/com/open/spring/mvc/person/PersonApiController.java +++ b/src/main/java/com/open/spring/mvc/person/PersonApiController.java @@ -1,4 +1,5 @@ package com.open.spring.mvc.person; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -82,7 +83,7 @@ public ResponseEntity getPerson(@AuthenticationPrincipal UserDetails use String email = userDetails.getUsername(); // Email is mapped/unmapped to username for Spring Security // Find a person by username - Person person = repository.findByUid(email); + Person person = repository.findByUid(email); // Return the person if found if (person != null) { @@ -147,7 +148,6 @@ public ResponseEntity deletePerson(@PathVariable long id) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } - @Autowired private UserStocksRepository userStocksRepository; @@ -166,7 +166,8 @@ public static class PersonDto { private String currentPassword; private String name; private String pfp; - private Boolean kasmServerNeeded; + private Boolean kasmServerNeeded; + private String faceData; } /** @@ -196,7 +197,7 @@ public ResponseEntity postPerson(@RequestBody PersonDto personDto) { responseObject.put("error", "A person with email '" + personDto.getEmail() + "' already exists"); return new ResponseEntity<>(responseObject.toString(), responseHeaders, HttpStatus.CONFLICT); } - + // Use canonical Spring Security role naming (ROLE_*) for new accounts. PersonRole defaultRole = personDetailsService.findRole("ROLE_USER"); if (defaultRole == null) { @@ -212,16 +213,16 @@ public ResponseEntity postPerson(@RequestBody PersonDto personDto) { } // A person object WITHOUT ID will create a new record in the database - Person person = new Person(personDto.getEmail(), personDto.getUid(),personDto.getPassword(),personDto.getSid(), personDto.getName(), "/images/default.png", true, defaultRole); + Person person = new Person(personDto.getEmail(), personDto.getUid(), personDto.getPassword(), + personDto.getSid(), personDto.getName(), "/images/default.png", true, defaultRole); personDetailsService.save(person); - HttpHeaders responseHeaders = new HttpHeaders(); responseHeaders.setContentType(MediaType.APPLICATION_JSON); JSONObject responseObject = new JSONObject(); - responseObject.put("response",personDto.getEmail() + " is created successfully"); + responseObject.put("response", personDto.getEmail() + " is created successfully"); return new ResponseEntity<>(responseObject.toString(), responseHeaders, HttpStatus.OK); } @@ -230,7 +231,7 @@ public ResponseEntity postPerson(@RequestBody PersonDto personDto) { * Retrieves all the Person entities in the database, people * * @return A ResponseEntity containing a list for Person entities - * @throws JsonProcessingException + * @throws JsonProcessingException */ @GetMapping("/people") public ResponseEntity getPeople() throws JsonProcessingException { @@ -240,17 +241,18 @@ public ResponseEntity getPeople() throws JsonProcessingException { // Return the variable in the ResponseEntity return new ResponseEntity<>(people, HttpStatus.OK); } - + /** * Retrieves a single page of the Person entities in the database, people * * @param personId The starting index of the page to retrieve. * @param pageSize The number of Person entities to include in the page. * @return A ResponseEntity containing a paginated list for Person entities - * @throws JsonProcessingException + * @throws JsonProcessingException */ @GetMapping("/people/page/{personId}") - public ResponseEntity getPeoplePage(@PathVariable int personId, @RequestParam int pageSize) throws JsonProcessingException { + public ResponseEntity getPeoplePage(@PathVariable int personId, @RequestParam int pageSize) + throws JsonProcessingException { List allPeople = repository.findAllByOrderByNameAsc(); int total = allPeople.size(); @@ -339,7 +341,8 @@ public ResponseEntity> bulkExtractPersons() { personDto.setEmail(person.getEmail()); personDto.setUid(person.getUid()); personDto.setSid(person.getSid()); - personDto.setPassword(person.getPassword()); // Optional: You may want to exclude passwords for security reasons + personDto.setPassword(person.getPassword()); // Optional: You may want to exclude passwords for security + // reasons personDto.setName(person.getName()); personDto.setPfp(person.getPfp()); personDto.setKasmServerNeeded(person.getKasmServerNeeded()); @@ -349,10 +352,11 @@ public ResponseEntity> bulkExtractPersons() { // Return the list of PersonDto objects return new ResponseEntity<>(personDtos, HttpStatus.OK); } + /** * Update a Person entity by its ID. * - * @param id The ID of the Person entity to update. + * @param id The ID of the Person entity to update. * @param personDto The updated PersonDto object. * @return A ResponseEntity containing the updated Person entity if found, or a * NOT_FOUND status if not found. @@ -373,18 +377,24 @@ public ResponseEntity updatePerson(Authentication authentication, @Reque if (optionalPerson.isPresent()) { Person existingPerson = optionalPerson.get(); boolean isAdmin = authentication.getAuthorities().stream() - .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); - boolean emailChanged = personDto.getEmail() != null && !personDto.getEmail().equals(existingPerson.getEmail()); + boolean emailChanged = personDto.getEmail() != null + && !personDto.getEmail().equals(existingPerson.getEmail()); boolean passwordChanged = personDto.getPassword() != null && !personDto.getPassword().isBlank(); boolean sidChanged = personDto.getSid() != null && !personDto.getSid().equals(existingPerson.getSid()); boolean nameChanged = personDto.getName() != null && !personDto.getName().equals(existingPerson.getName()); boolean pfpChanged = personDto.getPfp() != null && !personDto.getPfp().equals(existingPerson.getPfp()); - boolean kasmChanged = personDto.getKasmServerNeeded() != null && !personDto.getKasmServerNeeded().equals(existingPerson.getKasmServerNeeded()); - boolean uidChangeRequested = personDto.getUid() != null && !personDto.getUid().equals(existingPerson.getUid()); + boolean kasmChanged = personDto.getKasmServerNeeded() != null + && !personDto.getKasmServerNeeded().equals(existingPerson.getKasmServerNeeded()); + boolean uidChangeRequested = personDto.getUid() != null + && !personDto.getUid().equals(existingPerson.getUid()); + boolean faceDataChanged = personDto.getFaceData() != null + && !personDto.getFaceData().equals(existingPerson.getFaceData()); if (!isAdmin && uidChangeRequested) { - logger.warn("AUDIT profile_update_blocked actor={} reason=uid_change_not_allowed", existingPerson.getUid()); + logger.warn("AUDIT profile_update_blocked actor={} reason=uid_change_not_allowed", + existingPerson.getUid()); JSONObject responseObject = new JSONObject(); responseObject.put("error", "UID cannot be changed through this endpoint"); return new ResponseEntity<>(responseObject.toString(), HttpStatus.FORBIDDEN); @@ -392,9 +402,10 @@ public ResponseEntity updatePerson(Authentication authentication, @Reque if (!isAdmin && (emailChanged || passwordChanged)) { if (personDto.getCurrentPassword() == null - || personDto.getCurrentPassword().isBlank() - || !passwordEncoder.matches(personDto.getCurrentPassword(), existingPerson.getPassword())) { - logger.warn("AUDIT profile_update_blocked actor={} reason=invalid_current_password", existingPerson.getUid()); + || personDto.getCurrentPassword().isBlank() + || !passwordEncoder.matches(personDto.getCurrentPassword(), existingPerson.getPassword())) { + logger.warn("AUDIT profile_update_blocked actor={} reason=invalid_current_password", + existingPerson.getUid()); JSONObject responseObject = new JSONObject(); responseObject.put("error", "Current password is required for sensitive updates"); return new ResponseEntity<>(responseObject.toString(), HttpStatus.FORBIDDEN); @@ -434,7 +445,7 @@ public ResponseEntity updatePerson(Authentication authentication, @Reque existingPerson.setSid(personDto.getSid()); changedFields.append("sid,"); } - + if (nameChanged) { existingPerson.setName(personDto.getName()); changedFields.append("name,"); @@ -447,13 +458,18 @@ public ResponseEntity updatePerson(Authentication authentication, @Reque existingPerson.setKasmServerNeeded(personDto.getKasmServerNeeded()); changedFields.append("kasmServerNeeded,"); } + if (faceDataChanged) { + existingPerson.setFaceData(personDto.getFaceData()); + changedFields.append("faceData,"); + } // Save the updated person back to the repository Person updatedPerson = repository.save(existingPerson); String changed = changedFields.length() == 0 - ? "none" - : changedFields.substring(0, changedFields.length() - 1); - logger.info("AUDIT profile_update actor={} target={} fields={} admin={}", email, updatedPerson.getUid(), changed, isAdmin); + ? "none" + : changedFields.substring(0, changedFields.length() - 1); + logger.info("AUDIT profile_update actor={} target={} fields={} admin={}", email, updatedPerson.getUid(), + changed, isAdmin); // Return the updated person entity return new ResponseEntity<>(updatedPerson, HttpStatus.OK); @@ -463,8 +479,6 @@ public ResponseEntity updatePerson(Authentication authentication, @Reque return new ResponseEntity<>(HttpStatus.NOT_FOUND); } - - /** * Search for a Person entity by name or email. * @@ -485,73 +499,74 @@ public ResponseEntity personSearch(@RequestBody final Map(list, HttpStatus.OK); } - - @CrossOrigin(origins = {"*"}) + @CrossOrigin(origins = { "*" }) @GetMapping("/{sid}") - public ResponseEntity getNameById(@PathVariable String sid) - { + public ResponseEntity getNameById(@PathVariable String sid) { Person person = repository.findBySid(sid); - if(person != null) - { + if (person != null) { return ResponseEntity.ok(person.getName()); - } - else - { + } else { return ResponseEntity.ok("Not a valid barcode"); } }; - // @PostMapping(value = "/person/setSections", produces = MediaType.APPLICATION_JSON_VALUE) - // public ResponseEntity setSections(@AuthenticationPrincipal UserDetails userDetails, @RequestBody final List sections) { - // // Check if the authentication object is null - // if (userDetails == null) { - // return ResponseEntity - // .status(HttpStatus.UNAUTHORIZED) - // .body("Error: Authentication object is null. User is not authenticated."); - // } - - // String email = userDetails.getUsername(); - - // // Manually wrap the result in Optional.ofNullable - // Optional optional = Optional.ofNullable(repository.findByEmail(email)); - // if (optional.isPresent()) { - // Person person = optional.get(); - - // // Get existing sections and ensure it is not null - // Collection existingSections = person.getSections(); - // if (existingSections == null) { - // existingSections = new ArrayList<>(); - // } - - // // Add sections - // for (SectionDTO sectionDTO : sections) { - // if (!existingSections.stream().anyMatch(s -> s.getName().equals(sectionDTO.getName()))) { - // PersonSections newSection = new PersonSections(sectionDTO.getName(), sectionDTO.getAbbreviation(), sectionDTO.getYear()); - // existingSections.add(newSection); - // } else { - // return ResponseEntity - // .status(HttpStatus.CONFLICT) - // .body("Error: Section with name '" + sectionDTO.getName() + "' already exists."); - // } - // } - - // // Persist updated sections - // person.setSections(existingSections); - // repository.save(person); - - // // Return updated Person - // return ResponseEntity.ok(person); - // } - - // // Person not found - // return ResponseEntity - // .status(HttpStatus.NOT_FOUND) - // .body("Error: Person not found with email: " + email); + // @PostMapping(value = "/person/setSections", produces = + // MediaType.APPLICATION_JSON_VALUE) + // public ResponseEntity setSections(@AuthenticationPrincipal UserDetails + // userDetails, @RequestBody final List sections) { + // // Check if the authentication object is null + // if (userDetails == null) { + // return ResponseEntity + // .status(HttpStatus.UNAUTHORIZED) + // .body("Error: Authentication object is null. User is not authenticated."); + // } + + // String email = userDetails.getUsername(); + + // // Manually wrap the result in Optional.ofNullable + // Optional optional = + // Optional.ofNullable(repository.findByEmail(email)); + // if (optional.isPresent()) { + // Person person = optional.get(); + + // // Get existing sections and ensure it is not null + // Collection existingSections = person.getSections(); + // if (existingSections == null) { + // existingSections = new ArrayList<>(); + // } + + // // Add sections + // for (SectionDTO sectionDTO : sections) { + // if (!existingSections.stream().anyMatch(s -> + // s.getName().equals(sectionDTO.getName()))) { + // PersonSections newSection = new PersonSections(sectionDTO.getName(), + // sectionDTO.getAbbreviation(), sectionDTO.getYear()); + // existingSections.add(newSection); + // } else { + // return ResponseEntity + // .status(HttpStatus.CONFLICT) + // .body("Error: Section with name '" + sectionDTO.getName() + "' already + // exists."); + // } // } + // // Persist updated sections + // person.setSections(existingSections); + // repository.save(person); + + // // Return updated Person + // return ResponseEntity.ok(person); + // } + + // // Person not found + // return ResponseEntity + // .status(HttpStatus.NOT_FOUND) + // .body("Error: Person not found with email: " + email); + // } @PutMapping("/person/{id}") @PreAuthorize("hasAuthority('ROLE_ADMIN')") - public ResponseEntity updatePerson(Authentication authentication, @PathVariable long id, @RequestBody PersonDto personDto) { + public ResponseEntity updatePerson(Authentication authentication, @PathVariable long id, + @RequestBody PersonDto personDto) { if (authentication == null || authentication.getPrincipal() == null) { return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); } @@ -559,7 +574,7 @@ public ResponseEntity updatePerson(Authentication authentication, @PathV UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String actorUid = userDetails.getUsername(); boolean isAdmin = authentication.getAuthorities().stream() - .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); Person actorPerson = repository.findByUid(actorUid); if (actorPerson == null) { @@ -567,24 +582,28 @@ public ResponseEntity updatePerson(Authentication authentication, @PathV } Optional optional = repository.findById(id); - if (optional.isPresent()) { // If the person with the given ID exists + if (optional.isPresent()) { // If the person with the given ID exists Person existingPerson = optional.get(); if (!isAdmin && !existingPerson.getId().equals(actorPerson.getId())) { - logger.warn("AUDIT profile_update_blocked actor={} target={} reason=non_admin_cross_update", actorUid, existingPerson.getUid()); + logger.warn("AUDIT profile_update_blocked actor={} target={} reason=non_admin_cross_update", actorUid, + existingPerson.getUid()); return new ResponseEntity<>(HttpStatus.FORBIDDEN); } - boolean emailChanged = personDto.getEmail() != null && !personDto.getEmail().equals(existingPerson.getEmail()); + boolean emailChanged = personDto.getEmail() != null + && !personDto.getEmail().equals(existingPerson.getEmail()); boolean passwordChanged = personDto.getPassword() != null && !personDto.getPassword().isBlank(); boolean uidChanged = personDto.getUid() != null && !personDto.getUid().equals(existingPerson.getUid()); boolean nameChanged = personDto.getName() != null && !personDto.getName().equals(existingPerson.getName()); boolean pfpChanged = personDto.getPfp() != null && !personDto.getPfp().equals(existingPerson.getPfp()); - boolean kasmChanged = personDto.getKasmServerNeeded() != null && !personDto.getKasmServerNeeded().equals(existingPerson.getKasmServerNeeded()); + boolean kasmChanged = personDto.getKasmServerNeeded() != null + && !personDto.getKasmServerNeeded().equals(existingPerson.getKasmServerNeeded()); boolean sidChanged = personDto.getSid() != null && !personDto.getSid().equals(existingPerson.getSid()); if (!isAdmin && uidChanged) { - logger.warn("AUDIT profile_update_blocked actor={} target={} reason=uid_change_not_allowed", actorUid, existingPerson.getUid()); + logger.warn("AUDIT profile_update_blocked actor={} target={} reason=uid_change_not_allowed", actorUid, + existingPerson.getUid()); JSONObject responseObject = new JSONObject(); responseObject.put("error", "UID cannot be changed by non-admin users"); return new ResponseEntity<>(responseObject.toString(), HttpStatus.FORBIDDEN); @@ -592,9 +611,10 @@ public ResponseEntity updatePerson(Authentication authentication, @PathV if (!isAdmin && (emailChanged || passwordChanged)) { if (personDto.getCurrentPassword() == null - || personDto.getCurrentPassword().isBlank() - || !passwordEncoder.matches(personDto.getCurrentPassword(), existingPerson.getPassword())) { - logger.warn("AUDIT profile_update_blocked actor={} target={} reason=invalid_current_password", actorUid, existingPerson.getUid()); + || personDto.getCurrentPassword().isBlank() + || !passwordEncoder.matches(personDto.getCurrentPassword(), existingPerson.getPassword())) { + logger.warn("AUDIT profile_update_blocked actor={} target={} reason=invalid_current_password", + actorUid, existingPerson.getUid()); JSONObject responseObject = new JSONObject(); responseObject.put("error", "Current password is required for sensitive updates"); return new ResponseEntity<>(responseObject.toString(), HttpStatus.FORBIDDEN); @@ -640,7 +660,7 @@ public ResponseEntity updatePerson(Authentication authentication, @PathV existingPerson.setUid(personDto.getUid()); changedFields.append("uid,"); } - + // Optional: Update other fields if they exist in Person if (pfpChanged) { existingPerson.setPfp(personDto.getPfp()); @@ -654,14 +674,18 @@ public ResponseEntity updatePerson(Authentication authentication, @PathV existingPerson.setSid(personDto.getSid()); changedFields.append("sid,"); } - + if (personDto.getFaceData() != null) { + existingPerson.setFaceData(personDto.getFaceData()); + changedFields.append("faceData,"); + } // Save the updated person back to the repository repository.save(existingPerson); String changed = changedFields.length() == 0 - ? "none" - : changedFields.substring(0, changedFields.length() - 1); - logger.info("AUDIT profile_update actor={} target={} fields={} admin={}", actorUid, existingPerson.getUid(), changed, isAdmin); + ? "none" + : changedFields.substring(0, changedFields.length() - 1); + logger.info("AUDIT profile_update actor={} target={} fields={} admin={}", actorUid, existingPerson.getUid(), + changed, isAdmin); // Return the updated person entity return new ResponseEntity<>(existingPerson, HttpStatus.OK); @@ -675,44 +699,47 @@ public ResponseEntity updatePerson(Authentication authentication, @PathV * Retrieves the balance of a Person entity by its ID. * * @param id The ID of the Person entity whose balance is to be fetched. - * @return A ResponseEntity containing the balance if found, or a NOT_FOUND status if the person does not exist. + * @return A ResponseEntity containing the balance if found, or a NOT_FOUND + * status if the person does not exist. */ // @GetMapping("/person/{id}/balance") // public ResponseEntity getBalance(@PathVariable long id) { - // Optional optional = repository.findById(id); - // if (optional.isPresent()) { - // Person person = optional.get(); - - // // Assuming there is a getBalance() method or a balance field in Person - // Map response = new HashMap<>(); - // response.put("id", person.getId()); - // response.put("name", person.getName()); - // response.put("balance", person.getBanks().getBalance()); // Replace with actual logic if needed - - // return new ResponseEntity<>(response, HttpStatus.OK); - // } - // return new ResponseEntity<>("Person not found", HttpStatus.NOT_FOUND); + // Optional optional = repository.findById(id); + // if (optional.isPresent()) { + // Person person = optional.get(); + + // // Assuming there is a getBalance() method or a balance field in Person + // Map response = new HashMap<>(); + // response.put("id", person.getId()); + // response.put("name", person.getName()); + // response.put("balance", person.getBanks().getBalance()); // Replace with + // actual logic if needed + + // return new ResponseEntity<>(response, HttpStatus.OK); + // } + // return new ResponseEntity<>("Person not found", HttpStatus.NOT_FOUND); // } /** * Adds stats to the Person table * * @param stat_map is a JSON object, example format: - {"health": - {"date": "2021-01-01", - "measurements": - { - "weight": "150", - "height": "70", - "bmi": "21.52" - } - } - } + * {"health": + * {"date": "2021-01-01", + * "measurements": + * { + * "weight": "150", + * "height": "70", + * "bmi": "21.52" + * } + * } + * } * @return A ResponseEntity containing the Person entity with updated stats, or * a NOT_FOUND status if not found. */ @PostMapping(value = "/person/setStats", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity personStats(Authentication authentication, @RequestBody final Map stat_map) { + public ResponseEntity personStats(Authentication authentication, + @RequestBody final Map stat_map) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String email = userDetails.getUsername(); // Email is mapped/unmapped to username for Spring Security From 9bbf9b1d6ff5fa7404b35dd8d37448d16189ab6c Mon Sep 17 00:00:00 2001 From: Rbojja23 Date: Sun, 22 Mar 2026 22:11:54 -0700 Subject: [PATCH 05/15] Logic for bathroom pass finally works! --- .../spring/mvc/bathroom/BathroomQueue.java | 55 ++++++++++++++----- .../bathroom/BathroomQueueApiController.java | 46 +++++++++++++--- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java index a431f566..580d9be4 100644 --- a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java +++ b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueue.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.stream.Collectors; import jakarta.persistence.Column; @@ -57,33 +58,59 @@ public void addStudent(String studentName) { } } + /** + * Helper to check if a student is already in the queue + */ + public boolean containsStudent(String studentName) { + if (this.peopleQueue == null || this.peopleQueue.isEmpty()) + return false; + return Arrays.asList(this.peopleQueue.split(",")).contains(studentName); + } + + /** + * Helper to get the index of a student in the queue + */ + public int getStudentIndex(String studentName) { + if (this.peopleQueue == null || this.peopleQueue.isEmpty()) + return -1; + List students = Arrays.asList(this.peopleQueue.split(",")); + return students.indexOf(studentName); + } + /** * Function to remove the student from a queue * - * @param studentName - the name you want to remove from the queue. In frontend, - * your own name is passed. + * @param studentName - the name you want to remove from the queue. */ public void removeStudent(String studentName) { if (this.peopleQueue != null && !this.peopleQueue.isEmpty()) { String[] studentsBefore = this.peopleQueue.split(","); - this.peopleQueue = Arrays.stream(studentsBefore) - .filter(s -> !s.equals(studentName)) - .collect(Collectors.joining(",")); - String[] studentsAfter = this.peopleQueue.isEmpty() ? new String[0] : this.peopleQueue.split(","); - - // If a student was actually removed, and they were part of the 'active' count - // (or simply someone leaving) - if (studentsBefore.length > studentsAfter.length) { - if (this.away > 0) { - this.away--; + int studentIndex = -1; + for (int i = 0; i < studentsBefore.length; i++) { + if (studentsBefore[i].equals(studentName)) { + studentIndex = i; + break; + } + } + + if (studentIndex != -1) { + // Remove the student + this.peopleQueue = Arrays.stream(studentsBefore) + .filter(s -> !s.equals(studentName)) + .collect(Collectors.joining(",")); + + // ONLY decrease away if the student was actually in the "away" portion + if (studentIndex < this.away) { + if (this.away > 0) { + this.away--; + } } } } } /** - * @return - returns the student who is at the front of the line, removing the - * commas and sanitizing the data + * @return - returns the student who is at the front of the line */ public String getFrontStudent() { if (this.peopleQueue != null && !this.peopleQueue.isEmpty()) { diff --git a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java index 075cece2..30dd0ec8 100644 --- a/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java +++ b/src/main/java/com/open/spring/mvc/bathroom/BathroomQueueApiController.java @@ -31,7 +31,7 @@ * queue operations for classroom management. */ @RestController -@RequestMapping("/api/queue") // Base URL for all endpoints in this controller +@RequestMapping("/api/bathroom") // Updated mapping to match frontend @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com/" }) public class BathroomQueueApiController { @@ -109,17 +109,34 @@ public ResponseEntity addQueue(@RequestBody QueueAddReq request) { public ResponseEntity addToQueue(@RequestBody QueueDto queueDto) { // Check if a queue already exists for the given teacher Optional existingQueue = repository.findByTeacherEmail(queueDto.getTeacherEmail()); + if (existingQueue.isPresent()) { - // Add the student to the existing queue - existingQueue.get().addStudent(queueDto.getStudentName()); - repository.save(existingQueue.get()); // Save the updated queue to the database + BathroomQueue queue = existingQueue.get(); + if (queue.containsStudent(queueDto.getStudentName())) { + // TOGGLE: Student is already in queue, so remove them (checking back in) + queue.removeStudent(queueDto.getStudentName()); + repository.save(queue); + return new ResponseEntity<>(Map.of( + "action", "removed", + "message", queueDto.getStudentName() + " has checked back in."), HttpStatus.OK); + } else { + // TOGGLE: Student is not in queue, so add them + queue.addStudent(queueDto.getStudentName()); + repository.save(queue); + return new ResponseEntity<>(Map.of( + "action", "added", + "message", queueDto.getStudentName() + " was added to the queue."), HttpStatus.CREATED); + } } else { // Create a new queue for the teacher and add the student BathroomQueue newQueue = new BathroomQueue(queueDto.getTeacherEmail(), queueDto.getStudentName()); - repository.save(newQueue); // Save the new queue to the database + repository.save(newQueue); + return new ResponseEntity<>(Map.of( + "action", "added", + "message", + queueDto.getStudentName() + " was added to a new queue for " + queueDto.getTeacherEmail()), + HttpStatus.CREATED); } - return new ResponseEntity<>(queueDto.getStudentName() + " was added to " + queueDto.getTeacherEmail(), - HttpStatus.CREATED); } /** @@ -131,7 +148,7 @@ public ResponseEntity addToQueue(@RequestBody QueueDto queueDto) { * or a NOT_FOUND status if queue or student is not found */ @CrossOrigin(origins = { "http://localhost:8585", "https://pages.opencodingsociety.com" }) - @PostMapping("/remove") + @DeleteMapping("/remove") public ResponseEntity removeFromQueue(@RequestBody QueueDto queueDto) { Optional queueEntry = repository.findByTeacherEmail(queueDto.getTeacherEmail()); @@ -314,4 +331,17 @@ public ResponseEntity clearTable(@RequestParam(required = false) String role) "status", "success", "message", "All bathroom queue records have been cleared")); } + + /** + * Retrieves a specific teacher's bathroom queue. + * + * @param teacherEmail The teacher's email associated with the queue + * @return A ResponseEntity containing the BathroomQueue entity if found + */ + @GetMapping("/queue/{teacherEmail}") + public ResponseEntity getQueueByTeacher(@PathVariable String teacherEmail) { + return repository.findByTeacherEmail(teacherEmail) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).build()); + } } \ No newline at end of file From 193cb0f2527c0517638b5dda82c82d2384e15155 Mon Sep 17 00:00:00 2001 From: adikatre Date: Mon, 23 Mar 2026 10:58:57 -0700 Subject: [PATCH 06/15] ws on port 8589 with presence and typing indicators --- .../mvc/groups/GroupChatApiController.java | 35 +- .../spring/mvc/groups/GroupChatEvent.java | 26 ++ .../mvc/groups/GroupChatPresenceService.java | 148 +++++++ .../mvc/groups/GroupChatRealtimeService.java | 105 +++++ .../groups/GroupChatWebSocketController.java | 113 ++++++ .../GroupChatWebSocketEventListener.java | 36 ++ src/main/resources/templates/group/group.html | 372 ++++++++++++++++++ 7 files changed, 814 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/open/spring/mvc/groups/GroupChatEvent.java create mode 100644 src/main/java/com/open/spring/mvc/groups/GroupChatPresenceService.java create mode 100644 src/main/java/com/open/spring/mvc/groups/GroupChatRealtimeService.java create mode 100644 src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketController.java create mode 100644 src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketEventListener.java diff --git a/src/main/java/com/open/spring/mvc/groups/GroupChatApiController.java b/src/main/java/com/open/spring/mvc/groups/GroupChatApiController.java index 00cd9b3f..401e341c 100644 --- a/src/main/java/com/open/spring/mvc/groups/GroupChatApiController.java +++ b/src/main/java/com/open/spring/mvc/groups/GroupChatApiController.java @@ -7,7 +7,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,22 +27,20 @@ @CrossOrigin public class GroupChatApiController { - private static final String GROUP_TOPIC_PREFIX = "/topic/group/"; - private final GroupChatService groupChatService; + private final GroupChatRealtimeService realtimeService; private final GroupsJpaRepository groupsRepository; private final PersonJpaRepository personRepository; - private final SimpMessagingTemplate messagingTemplate; public GroupChatApiController( GroupChatService groupChatService, + GroupChatRealtimeService realtimeService, GroupsJpaRepository groupsRepository, - PersonJpaRepository personRepository, - SimpMessagingTemplate messagingTemplate) { + PersonJpaRepository personRepository) { this.groupChatService = groupChatService; + this.realtimeService = realtimeService; this.groupsRepository = groupsRepository; this.personRepository = personRepository; - this.messagingTemplate = messagingTemplate; } @Data @@ -130,9 +127,14 @@ public ResponseEntity postMessage( return new ResponseEntity<>("name and message are required", HttpStatus.BAD_REQUEST); } + try { + realtimeService.publishMessage(groupId, message.getName(), message.getMessage(), message.getImage()); + } catch (RuntimeException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } + String groupName = groupOpt.get().getName(); - List updated = groupChatService.addMessage(groupName, message); - messagingTemplate.convertAndSend(GROUP_TOPIC_PREFIX + groupId, message); + List updated = groupChatService.getMessages(groupName); return new ResponseEntity<>(updated, HttpStatus.OK); } @@ -177,22 +179,13 @@ public ResponseEntity uploadSharedFile( return new ResponseEntity<>("filename and base64Data are required", HttpStatus.BAD_REQUEST); } - String groupName = groupOpt.get().getName(); - String result = groupChatService.uploadSharedFile(groupName, request.getFilename(), request.getBase64Data()); - - if (result != null) { - GroupChatMessage uploadedFileMessage = new GroupChatMessage( - null, - request.getFilename(), - null, - request.getBase64Data()); - messagingTemplate.convertAndSend(GROUP_TOPIC_PREFIX + groupId, uploadedFileMessage); - + try { + realtimeService.publishFile(groupId, null, request.getFilename(), request.getBase64Data()); Map response = new HashMap<>(); response.put("message", "File uploaded successfully"); response.put("filename", request.getFilename()); return new ResponseEntity<>(response, HttpStatus.OK); - } else { + } catch (RuntimeException ex) { return new ResponseEntity<>("Upload failed", HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/com/open/spring/mvc/groups/GroupChatEvent.java b/src/main/java/com/open/spring/mvc/groups/GroupChatEvent.java new file mode 100644 index 00000000..9623c2e0 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/GroupChatEvent.java @@ -0,0 +1,26 @@ +package com.open.spring.mvc.groups; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GroupChatEvent { + private String context; + private Long groupId; + private String sender; + private String message; + private String image; + private String filename; + private String base64Data; + private String date; + private String sessionId; + private String error; + private List participants; +} diff --git a/src/main/java/com/open/spring/mvc/groups/GroupChatPresenceService.java b/src/main/java/com/open/spring/mvc/groups/GroupChatPresenceService.java new file mode 100644 index 00000000..d96c0b54 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/GroupChatPresenceService.java @@ -0,0 +1,148 @@ +package com.open.spring.mvc.groups; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +@Service +public class GroupChatPresenceService { + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap> participantCountsByGroup = new ConcurrentHashMap<>(); + + public void joinGroup(String sessionId, Long groupId, String username) { + if (sessionId == null || groupId == null || username == null || username.isBlank()) { + return; + } + + PresenceSession presenceSession = sessions.computeIfAbsent(sessionId, key -> new PresenceSession(username)); + presenceSession.setUsername(username); + presenceSession.getGroups().add(groupId); + + ConcurrentMap groupCounts = participantCountsByGroup.computeIfAbsent(groupId, key -> new ConcurrentHashMap<>()); + groupCounts.compute(username, (key, count) -> count == null ? 1 : count + 1); + } + + public void leaveGroup(String sessionId, Long groupId) { + if (sessionId == null || groupId == null) { + return; + } + + PresenceSession presenceSession = sessions.get(sessionId); + if (presenceSession == null) { + return; + } + + presenceSession.getGroups().remove(groupId); + + ConcurrentMap groupCounts = participantCountsByGroup.get(groupId); + if (groupCounts != null) { + decrementParticipantCount(groupId, presenceSession.getUsername(), groupCounts); + if (groupCounts.isEmpty()) { + participantCountsByGroup.remove(groupId); + } + } + + if (presenceSession.getGroups().isEmpty()) { + sessions.remove(sessionId); + } + } + + public boolean isSessionInGroup(String sessionId, Long groupId) { + PresenceSession presenceSession = sessions.get(sessionId); + return presenceSession != null && presenceSession.getGroups().contains(groupId); + } + + public String getSessionUsername(String sessionId) { + PresenceSession presenceSession = sessions.get(sessionId); + return presenceSession == null ? null : presenceSession.getUsername(); + } + + public List getParticipants(Long groupId) { + Map participantCounts = participantCountsByGroup.get(groupId); + if (participantCounts == null) { + return new ArrayList<>(); + } + + return participantCounts.keySet().stream() + .sorted(String::compareToIgnoreCase) + .collect(Collectors.toList()); + } + + public List removeSession(String sessionId) { + PresenceSession presenceSession = sessions.remove(sessionId); + if (presenceSession == null) { + return new ArrayList<>(); + } + + List disconnectedGroups = new ArrayList<>(); + for (Long groupId : new HashSet<>(presenceSession.getGroups())) { + ConcurrentMap groupCounts = participantCountsByGroup.get(groupId); + if (groupCounts != null) { + decrementParticipantCount(groupId, presenceSession.getUsername(), groupCounts); + if (groupCounts.isEmpty()) { + participantCountsByGroup.remove(groupId); + } + } + disconnectedGroups.add(new DisconnectedGroup(groupId, presenceSession.getUsername())); + } + + return disconnectedGroups; + } + + private void decrementParticipantCount(Long groupId, String username, ConcurrentMap groupCounts) { + groupCounts.computeIfPresent(username, (key, count) -> { + int next = count - 1; + return next > 0 ? next : null; + }); + if (groupCounts.isEmpty()) { + participantCountsByGroup.remove(groupId); + } + } + + public static class DisconnectedGroup { + private final Long groupId; + private final String username; + + public DisconnectedGroup(Long groupId, String username) { + this.groupId = groupId; + this.username = username; + } + + public Long getGroupId() { + return groupId; + } + + public String getUsername() { + return username; + } + } + + private static class PresenceSession { + private volatile String username; + private final Set groups = ConcurrentHashMap.newKeySet(); + + private PresenceSession(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Set getGroups() { + return groups; + } + } +} diff --git a/src/main/java/com/open/spring/mvc/groups/GroupChatRealtimeService.java b/src/main/java/com/open/spring/mvc/groups/GroupChatRealtimeService.java new file mode 100644 index 00000000..b986bf0a --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/GroupChatRealtimeService.java @@ -0,0 +1,105 @@ +package com.open.spring.mvc.groups; + +import java.time.Instant; +import java.util.Optional; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class GroupChatRealtimeService { + + public static final String GROUP_TOPIC_PREFIX = "/topic/group/"; + + private final GroupChatService groupChatService; + private final GroupsJpaRepository groupsRepository; + private final SimpMessagingTemplate messagingTemplate; + + public GroupChatEvent publishMessage(Long groupId, String sender, String message, String image) { + Groups group = getGroupOrThrow(groupId); + + String date = Instant.now().toString(); + GroupChatMessage persisted = new GroupChatMessage(sender, message, date, image); + groupChatService.addMessage(group.getName(), persisted); + + GroupChatEvent event = GroupChatEvent.builder() + .context("sendMessageServer") + .groupId(groupId) + .sender(sender) + .message(message) + .image(image) + .date(date) + .build(); + + broadcastToGroup(groupId, event); + return event; + } + + public GroupChatEvent publishFile(Long groupId, String sender, String filename, String base64Data) { + Groups group = getGroupOrThrow(groupId); + + String uploadResult = groupChatService.uploadSharedFile(group.getName(), filename, base64Data); + if (uploadResult == null) { + throw new IllegalStateException("Upload failed"); + } + + GroupChatEvent event = GroupChatEvent.builder() + .context("sendFileServer") + .groupId(groupId) + .sender(sender) + .filename(filename) + .base64Data(base64Data) + .date(Instant.now().toString()) + .build(); + + broadcastToGroup(groupId, event); + return event; + } + + public void publishPresence(Long groupId, String context, String sender, java.util.List participants) { + GroupChatEvent event = GroupChatEvent.builder() + .context(context) + .groupId(groupId) + .sender(sender) + .participants(participants) + .date(Instant.now().toString()) + .build(); + broadcastToGroup(groupId, event); + } + + public void publishTyping(Long groupId, String sender, boolean typing) { + GroupChatEvent event = GroupChatEvent.builder() + .context(typing ? "typingStartServer" : "typingStopServer") + .groupId(groupId) + .sender(sender) + .date(Instant.now().toString()) + .build(); + broadcastToGroup(groupId, event); + } + + public void publishError(Long groupId, String sender, String errorMessage) { + GroupChatEvent event = GroupChatEvent.builder() + .context("errorServer") + .groupId(groupId) + .sender(sender) + .error(errorMessage) + .date(Instant.now().toString()) + .build(); + broadcastToGroup(groupId, event); + } + + private void broadcastToGroup(Long groupId, GroupChatEvent event) { + messagingTemplate.convertAndSend(GROUP_TOPIC_PREFIX + groupId, event); + } + + private Groups getGroupOrThrow(Long groupId) { + Optional groupOpt = groupsRepository.findById(groupId); + if (groupOpt.isEmpty()) { + throw new IllegalArgumentException("Group not found"); + } + return groupOpt.get(); + } +} diff --git a/src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketController.java b/src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketController.java new file mode 100644 index 00000000..de4da37e --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketController.java @@ -0,0 +1,113 @@ +package com.open.spring.mvc.groups; + +import java.security.Principal; + +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class GroupChatWebSocketController { + + private final GroupChatRealtimeService realtimeService; + private final GroupChatPresenceService presenceService; + + @MessageMapping("/groups.chat") + public void handleGroupEvent(@Payload GroupChatEvent event, + Principal principal, + @Header("simpSessionId") String sessionId) { + if (event == null || event.getContext() == null) { + return; + } + + Long groupId = event.getGroupId(); + String sender = resolveSender(event, principal); + + switch (event.getContext()) { + case "joinGroup" -> { + if (groupId == null) { + return; + } + presenceService.joinGroup(sessionId, groupId, sender); + realtimeService.publishPresence(groupId, "joinGroupServer", sender, presenceService.getParticipants(groupId)); + } + case "leaveGroup" -> { + if (groupId == null) { + return; + } + presenceService.leaveGroup(sessionId, groupId); + realtimeService.publishPresence(groupId, "leaveGroupServer", sender, presenceService.getParticipants(groupId)); + } + case "sendMessage" -> { + if (groupId == null || event.getMessage() == null || event.getMessage().isBlank()) { + return; + } + ensureJoined(sessionId, groupId, sender); + try { + realtimeService.publishMessage(groupId, sender, event.getMessage(), event.getImage()); + } catch (RuntimeException ex) { + realtimeService.publishError(groupId, sender, ex.getMessage()); + } + } + case "sendFile" -> { + if (groupId == null || event.getFilename() == null || event.getFilename().isBlank() + || event.getBase64Data() == null || event.getBase64Data().isBlank()) { + return; + } + ensureJoined(sessionId, groupId, sender); + try { + realtimeService.publishFile(groupId, sender, event.getFilename(), event.getBase64Data()); + } catch (RuntimeException ex) { + realtimeService.publishError(groupId, sender, ex.getMessage()); + } + } + case "typingStart" -> { + if (groupId == null) { + return; + } + ensureJoined(sessionId, groupId, sender); + realtimeService.publishTyping(groupId, sender, true); + } + case "typingStop" -> { + if (groupId == null) { + return; + } + realtimeService.publishTyping(groupId, sender, false); + } + case "heartbeat" -> { + if (groupId == null) { + return; + } + realtimeService.publishPresence(groupId, "heartbeatServer", sender, presenceService.getParticipants(groupId)); + } + default -> { + if (groupId != null) { + realtimeService.publishError(groupId, sender, "Unsupported context: " + event.getContext()); + } + } + } + } + + private void ensureJoined(String sessionId, Long groupId, String sender) { + if (!presenceService.isSessionInGroup(sessionId, groupId)) { + presenceService.joinGroup(sessionId, groupId, sender); + realtimeService.publishPresence(groupId, "joinGroupServer", sender, presenceService.getParticipants(groupId)); + } + } + + private String resolveSender(GroupChatEvent event, Principal principal) { + if (event != null && event.getSender() != null && !event.getSender().isBlank()) { + return event.getSender().trim(); + } + + if (principal != null && principal.getName() != null && !principal.getName().isBlank()) { + return principal.getName(); + } + + return "anonymous"; + } +} diff --git a/src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketEventListener.java b/src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketEventListener.java new file mode 100644 index 00000000..661c4e5c --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/GroupChatWebSocketEventListener.java @@ -0,0 +1,36 @@ +package com.open.spring.mvc.groups; + +import java.util.List; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class GroupChatWebSocketEventListener { + + private final GroupChatPresenceService presenceService; + private final GroupChatRealtimeService realtimeService; + + @EventListener + public void handleSessionDisconnect(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + if (sessionId == null) { + return; + } + + List disconnectedGroups = presenceService.removeSession(sessionId); + for (GroupChatPresenceService.DisconnectedGroup disconnectedGroup : disconnectedGroups) { + realtimeService.publishPresence( + disconnectedGroup.getGroupId(), + "leaveGroupServer", + disconnectedGroup.getUsername(), + presenceService.getParticipants(disconnectedGroup.getGroupId())); + } + } +} diff --git a/src/main/resources/templates/group/group.html b/src/main/resources/templates/group/group.html index fc1dcfae..d408a89f 100644 --- a/src/main/resources/templates/group/group.html +++ b/src/main/resources/templates/group/group.html @@ -62,6 +62,11 @@

Group Management

onclick="return confirm('Are you sure you want to delete this group?')"> Delete + @@ -98,6 +103,40 @@

Group Management

+ +
+
+
+ Group Chat + No group selected +
+ Disconnected +
+
+
+
+ + +
+
Participants
+
    +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    @@ -242,6 +281,8 @@ + + From 401d213ea3521bd1522251651026cab381dcaf34 Mon Sep 17 00:00:00 2001 From: adikatre Date: Tue, 24 Mar 2026 10:30:08 -0700 Subject: [PATCH 07/15] works on my machine! ws chat --- .../mvc/groups/ChatWebSocketPortConfig.java | 32 ++++++++++++ .../mvc/groups/ChatWebSocketPortFilter.java | 48 +++++++++++++++++ .../spring/security/MvcSecurityConfig.java | 1 + .../open/spring/security/SecurityConfig.java | 6 ++- .../com/open/spring/system/MvcConfig.java | 12 +++-- src/main/resources/templates/group/group.html | 52 +++++-------------- 6 files changed, 107 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortConfig.java create mode 100644 src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortFilter.java diff --git a/src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortConfig.java b/src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortConfig.java new file mode 100644 index 00000000..8073d515 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortConfig.java @@ -0,0 +1,32 @@ +package com.open.spring.mvc.groups; + +import org.apache.catalina.connector.Connector; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ChatWebSocketPortConfig { + + @Value("${server.port:8585}") + private int serverPort; + + @Value("${socket.port:8589}") + private int socketPort; + + @Bean + public TomcatServletWebServerFactory tomcatServletWebServerFactory() { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(); + if (socketPort != serverPort) { + factory.addAdditionalTomcatConnectors(createConnector(socketPort)); + } + return factory; + } + + private Connector createConnector(int port) { + Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL); + connector.setPort(port); + return connector; + } +} diff --git a/src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortFilter.java b/src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortFilter.java new file mode 100644 index 00000000..81059522 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/groups/ChatWebSocketPortFilter.java @@ -0,0 +1,48 @@ +package com.open.spring.mvc.groups; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class ChatWebSocketPortFilter extends OncePerRequestFilter { + + @Value("${socket.port:8589}") + private int socketPort; + + private static final String CHAT_ENDPOINT = "/ws-chat"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String contextPath = request.getContextPath(); + String requestUri = request.getRequestURI(); + String path = requestUri.startsWith(contextPath) + ? requestUri.substring(contextPath.length()) + : requestUri; + + boolean isChatWebSocketPath = path.equals(CHAT_ENDPOINT) || path.startsWith(CHAT_ENDPOINT + "/"); + boolean isOnSocketPort = request.getLocalPort() == socketPort; + + // Port 8589 is dedicated to the chat websocket handshake/SockJS routes. + if (isOnSocketPort && !isChatWebSocketPath) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + // Keep websocket chat off the main application port. + if (!isOnSocketPort && isChatWebSocketPath) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/open/spring/security/MvcSecurityConfig.java b/src/main/java/com/open/spring/security/MvcSecurityConfig.java index c099e593..7081cc17 100644 --- a/src/main/java/com/open/spring/security/MvcSecurityConfig.java +++ b/src/main/java/com/open/spring/security/MvcSecurityConfig.java @@ -105,6 +105,7 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers("/mvc/grades/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/mvc/assignments/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/mvc/bank/read").hasAuthority("ROLE_ADMIN") + .requestMatchers(HttpMethod.OPTIONS, "/ws-chat/**").authenticated() .requestMatchers("/mvc/progress/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/ws-chat/**").authenticated() .requestMatchers("/run/**").permitAll() // Java runner endpoints - public access diff --git a/src/main/java/com/open/spring/security/SecurityConfig.java b/src/main/java/com/open/spring/security/SecurityConfig.java index 38b38431..481087ad 100644 --- a/src/main/java/com/open/spring/security/SecurityConfig.java +++ b/src/main/java/com/open/spring/security/SecurityConfig.java @@ -12,10 +12,10 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.util.matcher.OrRequestMatcher; -import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RegexRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -225,9 +225,11 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.addAllowedOriginPattern("http://127.0.0.1:4500"); configuration.addAllowedOriginPattern("http://127.0.0.1:4599"); configuration.addAllowedOriginPattern("http://127.0.0.1:4600"); + configuration.addAllowedOriginPattern("http://127.0.0.1:8585"); configuration.addAllowedOriginPattern("http://localhost:4500"); configuration.addAllowedOriginPattern("http://localhost:4599"); configuration.addAllowedOriginPattern("http://localhost:4600"); + configuration.addAllowedOriginPattern("http://localhost:8585"); configuration.addAllowedHeader("*"); configuration.addAllowedMethod("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/java/com/open/spring/system/MvcConfig.java b/src/main/java/com/open/spring/system/MvcConfig.java index 424206af..0680e1fc 100644 --- a/src/main/java/com/open/spring/system/MvcConfig.java +++ b/src/main/java/com/open/spring/system/MvcConfig.java @@ -1,10 +1,14 @@ package com.open.spring.system; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.lang.NonNull; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.context.annotation.*; -import org.springframework.web.servlet.config.annotation.*; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class MvcConfig implements WebMvcConfigurer { @@ -38,9 +42,11 @@ public void addCorsMappings(@NonNull CorsRegistry registry) { "http://127.0.0.1:4500", "http://127.0.0.1:4599", "http://127.0.0.1:4600", + "http://127.0.0.1:8585", "http://localhost:4500", "http://localhost:4599", - "http://localhost:4600" + "http://localhost:4600", + "http://localhost:8585" ) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true); diff --git a/src/main/resources/templates/group/group.html b/src/main/resources/templates/group/group.html index d408a89f..7e131035 100644 --- a/src/main/resources/templates/group/group.html +++ b/src/main/resources/templates/group/group.html @@ -129,10 +129,6 @@
    Participants
    -
    - - -
    @@ -285,10 +281,21 @@