From aa25cb3e72216089303c6d540c412cdd3e0619ee Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Fri, 8 May 2026 16:47:02 -0400 Subject: [PATCH 01/52] feat(board): complete PostgreSQL migration - entities, repos, services, controllers Converts all MusallahBoard domain models from MongoDB to JPA entities, creates JPA repositories under repository/sql/, rewrites BoardService and PosterService for JPA, updates transformers, assembler, controllers. Applies all model normalization fixes from PLAN.md section 0. Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 11 + .../lensbridge/LensBridgeApplication.java | 2 + .../controller/AdminController.java | 3 +- .../controller/BoardAdminController.java | 145 ++--------- .../request/CreateCalendarEventRequest.java | 27 +-- .../dto/request/UpdateBoardConfigRequest.java | 54 +---- .../request/UpdateCalendarEventRequest.java | 29 +-- .../dto/request/WeeklyContentRequest.java | 58 ++--- .../dto/response/DisplayConfig.java | 13 + .../dto/response/frames/EventView.java | 13 + .../lensbridge/model/audit/AuditEvent.java | 4 +- .../lensbridge/model/board/BoardConfig.java | 61 ++--- .../model/board/CalculationMethod.java | 11 + .../lensbridge/model/board/Event.java | 35 +-- .../lensbridge/model/board/IslamicQuote.java | 28 ++- .../lensbridge/model/board/JummahPrayer.java | 28 ++- .../lensbridge/model/board/Location.java | 13 +- .../lensbridge/model/board/Poster.java | 34 +-- .../lensbridge/model/board/WeeklyContent.java | 43 ++-- .../frames/DailyScheduleFrameConfig.java | 17 +- .../board/frames/EventListFrameConfig.java | 30 +-- .../model/board/frames/JummahFrameConfig.java | 2 +- .../repository/AuditEventRepository.java | 20 +- .../repository/sql/BoardConfigRepository.java | 9 + .../repository/sql/BoardEventRepository.java | 26 ++ .../repository/sql/PosterRepository.java | 27 +++ .../sql/WeeklyContentRepository.java | 16 ++ .../lensbridge/service/AdminAuditService.java | 8 +- .../lensbridge/service/BoardService.java | 229 +++++++----------- .../lensbridge/service/PosterService.java | 12 +- .../DailyScheduleFrameTransformer.java | 12 +- .../EventListFrameTransformer.java | 11 +- .../WeeklyContentFrameTransformer.java | 25 +- 33 files changed, 429 insertions(+), 627 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java diff --git a/pom.xml b/pom.xml index ce8ac20..761379c 100644 --- a/pom.xml +++ b/pom.xml @@ -157,6 +157,17 @@ spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.postgresql + postgresql + runtime + + diff --git a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java index c9901a4..7a2b3b3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java +++ b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableAsync +@EnableJpaRepositories(basePackages = "com.ibrasoft.lensbridge.repository.sql") public class LensBridgeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 75b3da1..d68ae5e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -26,6 +26,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.time.LocalDateTime; import java.util.Map; import java.util.UUID; @@ -282,7 +283,7 @@ public ResponseEntity getAuditEventsByAction(@PathVariable AdminAction action } @GetMapping("/audit/daterange") - public ResponseEntity getAuditEventsByDateRange(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end, Pageable pageable) { + public ResponseEntity getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, Pageable pageable) { try { log.debug("Admin retrieving audit events between {} and {}", start, end); Page events = auditService.getAuditEventsByDateRange(start, end, pageable); diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index a2b6e77..2404835 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -57,51 +57,39 @@ public class BoardAdminController { private final AdminAuditService auditService; @Autowired private SignboardHandler signboardHandler; - + // ==================== Board Config Endpoints ==================== - /** - * Get all board configurations. - */ @GetMapping("/configs") public ResponseEntity> getAllBoardConfigs() { log.debug("Admin fetching all board configs"); return ResponseEntity.ok(boardService.getAllBoardConfigs()); } - /** - * Get board configuration for a specific location. - */ @GetMapping("/configs/{boardLocation}") public ResponseEntity getBoardConfig(@PathVariable BoardLocation boardLocation) { log.debug("Admin fetching board config for: {}", boardLocation); return ResponseEntity.ok(boardService.getBoardConfigOrThrow(boardLocation)); } - /** - * Create or replace a board configuration. - */ @PutMapping("/configs/{boardLocation}") public ResponseEntity saveBoardConfig( @PathVariable BoardLocation boardLocation, @Valid @RequestBody BoardConfig boardConfig, HttpServletRequest request) { - + log.info("Admin saving board config for: {}", boardLocation); boardConfig.setBoardLocation(boardLocation); BoardConfig saved = boardService.saveBoardConfig(boardConfig); return ResponseEntity.ok(saved); } - /** - * Update board configuration (partial update). - */ @PatchMapping("/configs/{boardLocation}") public ResponseEntity updateBoardConfig( @PathVariable BoardLocation boardLocation, @Valid @RequestBody UpdateBoardConfigRequest updateRequest, HttpServletRequest request) { - + log.info("Admin updating board config for: {}", boardLocation); BoardConfig updated = boardService.updateBoardConfig(boardLocation, updateRequest); return ResponseEntity.ok(updated); @@ -109,27 +97,18 @@ public ResponseEntity updateBoardConfig( // ==================== Weekly Content Endpoints ==================== - /** - * Get all weekly content. - */ @GetMapping("/weekly-content") public ResponseEntity> getAllWeeklyContent() { log.debug("Admin fetching all weekly content"); return ResponseEntity.ok(boardService.getAllWeeklyContent()); } - /** - * Get weekly content for a specific year. - */ @GetMapping("/weekly-content/year/{year}") public ResponseEntity> getWeeklyContentByYear(@PathVariable int year) { log.debug("Admin fetching weekly content for year: {}", year); return ResponseEntity.ok(boardService.getWeeklyContentByYear(year)); } - /** - * Get weekly content for a specific week. - */ @GetMapping("/weekly-content/{year}/{weekNumber}") public ResponseEntity getWeeklyContent( @PathVariable int year, @@ -138,29 +117,24 @@ public ResponseEntity getWeeklyContent( return ResponseEntity.ok(boardService.getWeeklyContentOrThrow(year, weekNumber)); } - /** - * Create or update weekly content. - */ - @PutMapping("/weekly-content") + @PutMapping("/weekly-content/{year}/{weekNumber}") public ResponseEntity saveWeeklyContent( + @PathVariable int year, + @PathVariable int weekNumber, @Valid @RequestBody WeeklyContentRequest contentRequest, HttpServletRequest request) { - - log.info("Admin saving weekly content for week {} of {}", - contentRequest.getWeekNumber(), contentRequest.getYear()); - WeeklyContent saved = boardService.saveWeeklyContent(contentRequest); + + log.info("Admin saving weekly content for week {} of {}", weekNumber, year); + WeeklyContent saved = boardService.saveWeeklyContent(year, weekNumber, contentRequest); return ResponseEntity.ok(saved); } - /** - * Delete weekly content. - */ @DeleteMapping("/weekly-content/{year}/{weekNumber}") public ResponseEntity deleteWeeklyContent( @PathVariable int year, @PathVariable int weekNumber, HttpServletRequest request) { - + log.info("Admin deleting weekly content for week {} of {}", weekNumber, year); boardService.deleteWeeklyContent(year, weekNumber); return ResponseEntity.ok(new MessageResponse("Weekly content deleted successfully")); @@ -168,19 +142,12 @@ public ResponseEntity deleteWeeklyContent( // ==================== Poster Endpoints ==================== - /** - * Get all posters sorted by newest first (most recent startDate). - */ @GetMapping("/posters") public ResponseEntity> getAllPosters() { log.debug("Admin fetching all posters"); return ResponseEntity.ok(posterService.getAllPosters()); } - /** - * Get posters for a specific board location. - * Returns posters matching the board's audience or BOTH, sorted by newest first. - */ @GetMapping("/posters/by-board") public ResponseEntity> getPostersForBoard( @RequestParam("board") BoardLocation boardLocation) { @@ -188,18 +155,12 @@ public ResponseEntity> getPostersForBoard( return ResponseEntity.ok(posterService.getPostersForBoard(boardLocation)); } - /** - * Get a single poster by ID. - */ @GetMapping("/posters/{posterId}") public ResponseEntity getPosterById(@PathVariable UUID posterId) { log.debug("Admin fetching poster: {}", posterId); return ResponseEntity.ok(posterService.getPosterById(posterId)); } - /** - * Create a new poster with an uploaded image. - */ @PostMapping(value = "/posters", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createPoster( @RequestParam("title") String title, @@ -222,63 +183,50 @@ public ResponseEntity createPoster( Poster response = posterService.createPoster(createRequest, imageFile); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } - /** - * Update poster metadata (duration, dates, audience). - */ @PatchMapping("/posters/{posterId}") public ResponseEntity updatePoster( @PathVariable UUID posterId, @Valid @RequestBody UpdatePosterRequest updateRequest, HttpServletRequest request) { - + log.info("Admin updating poster: {}", posterId); Poster response = posterService.updatePoster(posterId, updateRequest); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } - /** - * Update poster image. - */ @PutMapping(value = "/posters/{posterId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updatePosterImage( @PathVariable UUID posterId, @RequestParam("image") MultipartFile imageFile, HttpServletRequest request) { - + log.info("Admin updating poster image: {}", posterId); Poster response = posterService.updatePosterImage(posterId, imageFile); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } - /** - * Delete a poster. - */ @DeleteMapping("/posters/{posterId}") public ResponseEntity deletePoster( @PathVariable UUID posterId, HttpServletRequest request) { - + log.info("Admin deleting poster: {}", posterId); posterService.deletePoster(posterId); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); @@ -287,119 +235,68 @@ public ResponseEntity deletePoster( // ==================== Calendar Event Endpoints ==================== - /** - * Get all calendar events. - */ @GetMapping("/events") public ResponseEntity> getAllEvents() { log.debug("Admin fetching all calendar events"); - List events = boardService.getAllEvents(); - return ResponseEntity.ok(events); + return ResponseEntity.ok(boardService.getAllEvents()); } - /** - * Get calendar events for a specific board location. - * Returns events matching the board's audience or BOTH. - */ @GetMapping("/events/by-board") public ResponseEntity> getEventsForBoard( @RequestParam("board") BoardLocation boardLocation) { log.debug("Admin fetching calendar events for board: {}", boardLocation); - List events = boardService.getEventsForBoard(boardLocation); - return ResponseEntity.ok(events); + return ResponseEntity.ok(boardService.getEventsForBoard(boardLocation)); } - /** - * Get a single calendar event by ID. - */ @GetMapping("/events/{eventId}") public ResponseEntity getEventById(@PathVariable UUID eventId) { log.debug("Admin fetching calendar event: {}", eventId); - Event event = boardService.getEventById(eventId); - return ResponseEntity.ok(event); + return ResponseEntity.ok(boardService.getEventById(eventId)); } - /** - * Create a new calendar event. - */ @PostMapping("/events") public ResponseEntity createEvent( @Valid @RequestBody CreateCalendarEventRequest createRequest, HttpServletRequest request) { - - log.info("Admin creating new calendar event: name={}", createRequest.getName()); - - Event event = Event.builder() - .name(createRequest.getName()) - .description(createRequest.getDescription()) - .location(createRequest.getLocation()) - .startTimestamp(createRequest.getStartTimestamp()) - .endTimestamp(createRequest.getEndTimestamp()) - .allDay(createRequest.getAllDay()) - .audience(createRequest.getAudience()) - .build(); - Event created = boardService.createEvent(event); + log.info("Admin creating new calendar event: name={}", createRequest.getName()); + Event created = boardService.createEvent(createRequest); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(created); } - /** - * Update a calendar event. - */ @PatchMapping("/events/{eventId}") public ResponseEntity updateEvent( @PathVariable UUID eventId, @Valid @RequestBody UpdateCalendarEventRequest updateRequest, HttpServletRequest request) { - - log.info("Admin updating calendar event: {}", eventId); - Event updates = Event.builder() - .name(updateRequest.getName()) - .description(updateRequest.getDescription()) - .location(updateRequest.getLocation()) - .startTimestamp(updateRequest.getStartTimestamp()) - .endTimestamp(updateRequest.getEndTimestamp()) - .allDay(updateRequest.getAllDay()) - .audience(updateRequest.getAudience()) - .build(); - - Event updated = boardService.updateEvent(eventId, updates); + log.info("Admin updating calendar event: {}", eventId); + Event updated = boardService.updateEvent(eventId, updateRequest); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(updated); } - /** - * Delete a calendar event. - */ @DeleteMapping("/events/{eventId}") public ResponseEntity deleteEvent( @PathVariable UUID eventId, HttpServletRequest request) { - + log.info("Admin deleting calendar event: {}", eventId); boardService.deleteEvent(eventId); - // Audit the action UserDetailsImpl user = getCurrentUser(); auditService.logAuditEvent(user.getEmail(), AdminAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Calendar event deleted successfully")); } - /** - * Refresh a MusallahBoard instance - * @return - */ @PostMapping("/refresh") public ResponseEntity refreshSignboard() { signboardHandler.sendRefreshCommand(); diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java index db3ef98..e6eb97b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java @@ -1,36 +1,21 @@ package com.ibrasoft.lensbridge.dto.request; import com.ibrasoft.lensbridge.model.board.Audience; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateCalendarEventRequest { - @NotBlank(message = "Event name is required") private String name; - private String description; - private String location; - - @Positive(message = "Start timestamp must be positive") - private long startTimestamp; - - @Positive(message = "End timestamp must be positive") - private long endTimestamp; - + @NotNull(message = "Start time is required") + private Long startEpochMs; + @NotNull(message = "End time is required") + private Long endEpochMs; private Boolean allDay; - @NotNull(message = "Audience is required") private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java index 67dc6fa..0966639 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java @@ -1,60 +1,20 @@ package com.ibrasoft.lensbridge.dto.request; import com.ibrasoft.lensbridge.model.board.Location; - import jakarta.validation.constraints.Min; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - +import lombok.*; import java.util.List; -/** - * Request DTO for updating board configuration. - * All fields are optional - only non-null fields will be updated. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateBoardConfigRequest { - - /** - * The prayer location details. - */ private Location location; - - /** - * Default poster cycle duration in milliseconds. - */ @Min(value = 1000, message = "Poster cycle interval must be at least 1000ms") - private Integer posterCycleInterval; - - /** - * How long to wait before refreshing data after Ishaa. - */ - @Min(value = 0, message = "Refresh after Ishaa minutes cannot be negative") - private Integer refreshAfterIshaaMinutes; - - /** - * Enable dark mode after Ishaa. - */ + private Integer posterCycleIntervalMs; + @Min(value = 0) + private Integer refreshAfterIshaMinutes; private Boolean darkModeAfterIsha; - - /** - * How long to wait after isha (in minutes) before enabling dark mode. - */ - @Min(value = 0, message = "Dark mode minutes after Isha cannot be negative") - private Integer darkModeMinutesAfterIsha; - - /** - * Enable scrolling message at the bottom of the screen. - */ + @Min(value = 0) + private Integer darkModeAfterIshaMinutes; private Boolean enableScrollingMessage; - - /** - * The scrolling message texts (will rotate through them). - */ private List scrollingMessages; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java index 13f3635..9e09a3e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java @@ -1,36 +1,15 @@ package com.ibrasoft.lensbridge.dto.request; import com.ibrasoft.lensbridge.model.board.Audience; +import lombok.*; -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Request DTO for updating calendar event information. - * All fields are optional - only non-null fields will be updated. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateCalendarEventRequest { - private String name; - private String description; - private String location; - - @Positive(message = "Start timestamp must be positive") - private Long startTimestamp; - - @Positive(message = "End timestamp must be positive") - private Long endTimestamp; - + private Long startEpochMs; + private Long endEpochMs; private Boolean allDay; - private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java index d83e109..d5fbd7d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java @@ -1,49 +1,23 @@ package com.ibrasoft.lensbridge.dto.request; import com.ibrasoft.lensbridge.model.board.IslamicQuote; -import com.ibrasoft.lensbridge.model.board.JummahPrayer; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - +import lombok.*; import java.util.List; -/** - * Request DTO for creating or updating weekly content. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class WeeklyContentRequest { - - @NotNull(message = "Year is required") - @Min(value = 2020, message = "Year must be 2020 or later") - @Max(value = 2100, message = "Year must be before 2100") - private Integer year; - - @NotNull(message = "Week number is required") - @Min(value = 1, message = "Week number must be between 1 and 53") - @Max(value = 53, message = "Week number must be between 1 and 53") - private Integer weekNumber; - - /** - * Quranic verse for the week. - */ - private IslamicQuote verse; - - /** - * Hadith for the week. - */ - private IslamicQuote hadith; - - /** - * Jummah prayer details for the week. - */ - private List jummahPrayer; + private List quotes; + private List jummahPrayers; + + public record QuoteEntry( + IslamicQuote.Kind kind, + String arabic, + String transliteration, + String translation, + String reference) {} + + public record JummahSlot( + String prayerTime, + String khatib, + String room) {} } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java new file mode 100644 index 0000000..f29e8ca --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.dto.response; + +import lombok.*; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class DisplayConfig { + private int posterCycleIntervalMs; + private int refreshAfterIshaMinutes; + private boolean darkModeAfterIsha; + private int darkModeAfterIshaMinutes; + private boolean enableScrollingMessage; + private java.util.List scrollingMessages; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java new file mode 100644 index 0000000..be6aa58 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.dto.response.frames; + +import lombok.*; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class EventView { + private String name; + private String description; + private String location; + private Long startEpochMs; + private Long endEpochMs; + private Boolean allDay; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java index eae5701..7ac94d4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java @@ -5,7 +5,7 @@ import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; @Data @@ -14,7 +14,7 @@ public class AuditEvent { @Id private String id; - private LocalDateTime timestamp; + private Instant timestamp; private String adminEmail; private UUID adminId; private AdminAction action; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java index deb0a10..d898209 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java @@ -1,61 +1,30 @@ package com.ibrasoft.lensbridge.model.board; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - +import jakarta.persistence.*; +import lombok.*; import java.util.List; -@Document("board_configs") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "board_configs") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class BoardConfig { - /** - * The location of the board (e.g., brothers' musallah, sisters' musallah). - * Since there will only be one of each, we use the location as the document ID. - * Two-for-one design! - */ @Id + @Enumerated(EnumType.STRING) + @Column(name = "board_location", length = 50) private BoardLocation boardLocation; - /** - * The prayer location details (city, country, coordinates, timezone, calculation method). - */ + @Embedded private Location location; - /** - * Default poster cycle duration in milliseconds - */ - private int posterCycleInterval; - - /** - * How long to wait before refreshing data after Ishaa - */ - private int refreshAfterIshaaMinutes; - - /** - * Enable dark mode after Ishaa - */ + private int posterCycleIntervalMs; + private int refreshAfterIshaMinutes; private boolean darkModeAfterIsha; - - /** - * How long to wait after isha (in minutes) before enabling dark mode - */ - private int darkModeMinutesAfterIsha; - - /** - * Enable scrolling message at the bottom of the screen - */ + private int darkModeAfterIshaMinutes; private boolean enableScrollingMessage; - /** - * The scrolling message texts (will rotate through them) - */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "board_config_messages", + joinColumns = @JoinColumn(name = "board_location")) + @Column(name = "message") private List scrollingMessages; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java b/src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java new file mode 100644 index 0000000..4d698fc --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/CalculationMethod.java @@ -0,0 +1,11 @@ +package com.ibrasoft.lensbridge.model.board; + +public enum CalculationMethod { + KARACHI(1), ISNA(2), MWL(3), MAKKAH(4), EGYPT(5), + TEHRAN(7), GULF(8), KUWAIT(9), QATAR(10), SINGAPORE(11), + FRANCE(12), TURKEY(13), RUSSIA(14), DUBAI(16); + + private final int code; + CalculationMethod(int code) { this.code = code; } + public int getCode() { return code; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java index 7e6841d..c35d997 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java @@ -1,39 +1,22 @@ package com.ibrasoft.lensbridge.model.board; -import java.time.LocalDateTime; +import jakarta.persistence.*; +import lombok.*; import java.util.UUID; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Document(collection = "board_events") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "board_events") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Event { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - - /** - * Event name/title - */ private String name; - private String description; - private String location; - - private Long startTimestamp; - - private Long endTimestamp; - + private Long startEpochMs; + private Long endEpochMs; private Boolean allDay; - + @Enumerated(EnumType.STRING) private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java b/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java index 080bd9e..3ff3781 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java @@ -1,15 +1,27 @@ package com.ibrasoft.lensbridge.model.board; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; +import java.util.UUID; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "islamic_quotes") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class IslamicQuote { + public enum Kind { VERSE, HADITH } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_content_id", nullable = false) + @JsonIgnore + private WeeklyContent weeklyContent; + + @Enumerated(EnumType.STRING) + private Kind kind; private String arabic; private String transliteration; private String translation; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java b/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java index 1794ff8..8bb0a2c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/JummahPrayer.java @@ -1,19 +1,25 @@ package com.ibrasoft.lensbridge.model.board; -import java.time.LocalDate; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.*; import java.time.LocalTime; +import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "jummah_prayers") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class JummahPrayer { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_content_id", nullable = false) + @JsonIgnore + private WeeklyContent weeklyContent; + private LocalTime prayerTime; private String khatib; - private String location; + private String room; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java index 2a29ad2..3d382b2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Location.java @@ -1,17 +1,16 @@ package com.ibrasoft.lensbridge.model.board; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; +import jakarta.persistence.*; +import lombok.*; -@Builder -@Data -@AllArgsConstructor +@Embeddable +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Location { private String city; private String country; private double latitude; private double longitude; private String timezone; - private int method; + @Enumerated(EnumType.STRING) + private CalculationMethod method; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java index 71966be..f655b94 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java @@ -1,40 +1,22 @@ package com.ibrasoft.lensbridge.model.board; +import jakarta.persistence.*; +import lombok.*; import java.time.LocalDate; import java.util.UUID; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Document(collection = "posters") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "posters") +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Poster { @Id - public UUID id; - + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; private String title; - private String image; - private int duration; - - /** - * The date from which the poster becomes active (inclusive) - */ private LocalDate startDate; - - /** - * The date until which the poster remains active (exclusive) - */ private LocalDate endDate; - + @Enumerated(EnumType.STRING) private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java b/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java index c4303ed..95b4b2f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/WeeklyContent.java @@ -1,23 +1,34 @@ package com.ibrasoft.lensbridge.model.board; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; import java.util.List; +import java.util.UUID; -@Document(collection = "weekly_content") -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Entity +@Table(name = "weekly_content", + uniqueConstraints = @UniqueConstraint(columnNames = {"year", "week_number"})) +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class WeeklyContent { @Id - private WeekId weekId; - private IslamicQuote verse; - private IslamicQuote hadith; - private List jummahPrayer; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "year", nullable = false) + private int year; + + @Column(name = "week_number", nullable = false) + private int weekNumber; + + @OneToMany(mappedBy = "weeklyContent", cascade = CascadeType.ALL, + orphanRemoval = true, fetch = FetchType.EAGER) + @Builder.Default + private List quotes = new ArrayList<>(); + + @OneToMany(mappedBy = "weeklyContent", cascade = CascadeType.ALL, + orphanRemoval = true, fetch = FetchType.EAGER) + @OrderColumn(name = "slot_order") + @Builder.Default + private List jummahPrayers = new ArrayList<>(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java index 82bcc4e..ad706d7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java @@ -1,21 +1,12 @@ package com.ibrasoft.lensbridge.model.board.frames; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - +import com.ibrasoft.lensbridge.dto.response.frames.EventView; +import lombok.*; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class DailyScheduleFrameConfig extends FrameConfig { - private String heading; - - private List events; + private List events; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java index e9acc3b..e4b1de4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java @@ -1,36 +1,12 @@ package com.ibrasoft.lensbridge.model.board.frames; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - +import com.ibrasoft.lensbridge.dto.response.frames.EventView; +import lombok.*; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class EventListFrameConfig extends FrameConfig { - - /** e.g. "This Week" */ private String heading; - - /** Flat presentation records */ private List events; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class EventView { - private String name; - private String description; - private String location; - private Long startTimestamp; - private Long endTimestamp; - private Boolean allDay; - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java index 336a507..12c2316 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/JummahFrameConfig.java @@ -25,6 +25,6 @@ public static class JummahSlot { /** ISO-8601 LocalTime string, e.g. "13:30" */ private String prayerTime; private String khatib; - private String location; + private String room; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java index 973b8be..9660c10 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java @@ -8,28 +8,28 @@ import org.springframework.data.mongodb.repository.Query; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.UUID; @Repository public interface AuditEventRepository extends MongoRepository { - + List findByAdminIdOrderByTimestampDesc(UUID adminId); - + List findByEntityTypeAndEntityIdOrderByTimestampDesc(String entityType, UUID entityId); - + Page findByActionOrderByTimestampDesc(AdminAction action, Pageable pageable); - + @Query("{'timestamp': {'$gte': ?0, '$lte': ?1}}") - Page findByTimestampBetweenOrderByTimestampDesc(LocalDateTime start, LocalDateTime end, Pageable pageable); - + Page findByTimestampBetweenOrderByTimestampDesc(Instant start, Instant end, Pageable pageable); + @Query("{'result': {'$regex': '^ERROR|FAILED', '$options': 'i'}}") Page findFailedOperationsOrderByTimestampDesc(Pageable pageable); - + Page findAllByOrderByTimestampDesc(Pageable pageable); - + long countByAdminId(UUID adminId); - + long countByAction(AdminAction action); } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java new file mode 100644 index 0000000..a01a932 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java @@ -0,0 +1,9 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.BoardConfig; +import com.ibrasoft.lensbridge.model.board.BoardLocation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BoardConfigRepository extends JpaRepository {} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java new file mode 100644 index 0000000..690a4a6 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Event; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface BoardEventRepository extends JpaRepository { + + List findAllByOrderByStartEpochMsAsc(); + + @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY e.startEpochMs ASC") + List findByAudienceOrBoth(@Param("aud") Audience audience); + + @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startEpochMs >= :now ORDER BY e.startEpochMs ASC") + List findUpcomingByAudienceOrBoth(@Param("aud") Audience audience, @Param("now") long now); + + @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startEpochMs <= :rangeEnd AND e.endEpochMs >= :rangeStart ORDER BY e.startEpochMs ASC") + List findOverlappingForAudienceOrBoth(@Param("aud") Audience audience, @Param("rangeStart") long rangeStart, @Param("rangeEnd") long rangeEnd); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java new file mode 100644 index 0000000..696f9b6 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java @@ -0,0 +1,27 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Poster; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Repository +public interface PosterRepository extends JpaRepository { + + List findAllByOrderByStartDateDesc(); + + @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY p.startDate DESC") + List findByAudienceOrBoth(@Param("aud") Audience audience); + + @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND p.startDate <= :today AND p.endDate > :today ORDER BY p.startDate DESC") + List findActivePostersForAudienceAt(@Param("today") LocalDate today, @Param("aud") Audience audience); + + @Query("SELECT p FROM Poster p WHERE p.startDate <= :today AND p.endDate > :today") + List findActivePostersAt(@Param("today") LocalDate today); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java new file mode 100644 index 0000000..6ee53fb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/WeeklyContentRepository.java @@ -0,0 +1,16 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.WeeklyContent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface WeeklyContentRepository extends JpaRepository { + Optional findByYearAndWeekNumber(int year, int weekNumber); + boolean existsByYearAndWeekNumber(int year, int weekNumber); + List findByYear(int year); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index c63cec6..80b1491 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -9,7 +9,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -22,7 +22,7 @@ public class AdminAuditService { public AuditEvent logAuditEvent(AuditEvent event) { try { - event.setTimestamp(LocalDateTime.now()); + event.setTimestamp(Instant.now()); AuditEvent savedEvent = auditEventRepository.save(event); log.debug("Audit event saved: {} by {} on {}", event.getAction(), event.getAdminEmail(), event.getEntityType()); return savedEvent; @@ -33,7 +33,7 @@ public AuditEvent logAuditEvent(AuditEvent event) { } public AuditEvent logAuditEvent(String adminEmail, AdminAction action, String entityType, UUID entityId, String IPAddress) { - AuditEvent event = AuditEvent.builder().adminEmail(adminEmail).action(action).entityType(entityType).entityId(entityId).timestamp(LocalDateTime.now()).ipAddress(IPAddress).build(); + AuditEvent event = AuditEvent.builder().adminEmail(adminEmail).action(action).entityType(entityType).entityId(entityId).timestamp(Instant.now()).ipAddress(IPAddress).build(); return logAuditEvent(event); } @@ -53,7 +53,7 @@ public Page getAuditEventsByAction(AdminAction action, Pageable page return auditEventRepository.findByActionOrderByTimestampDesc(action, pageable); } - public Page getAuditEventsByDateRange(LocalDateTime start, LocalDateTime end, Pageable pageable) { + public Page getAuditEventsByDateRange(Instant start, Instant end, Pageable pageable) { return auditEventRepository.findByTimestampBetweenOrderByTimestampDesc(start, end, pageable); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 0e8d74d..b237cdd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -1,26 +1,25 @@ package com.ibrasoft.lensbridge.service; +import com.ibrasoft.lensbridge.dto.request.CreateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.request.UpdateBoardConfigRequest; +import com.ibrasoft.lensbridge.dto.request.UpdateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import com.ibrasoft.lensbridge.model.board.Event; -import com.ibrasoft.lensbridge.model.board.WeekId; -import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import com.ibrasoft.lensbridge.repository.BoardConfigRepository; -import com.ibrasoft.lensbridge.repository.EventRepository; -import com.ibrasoft.lensbridge.repository.WeeklyContentRepository; +import com.ibrasoft.lensbridge.model.board.*; +import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; +import com.ibrasoft.lensbridge.repository.sql.BoardEventRepository; +import com.ibrasoft.lensbridge.repository.sql.WeeklyContentRepository; import com.ibrasoft.lensbridge.util.Patch; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -31,23 +30,15 @@ public class BoardService { private final BoardConfigRepository boardConfigRepository; - private final EventRepository eventRepository; + private final BoardEventRepository boardEventRepository; private final WeeklyContentRepository weeklyContentRepository; - private static final Sort SORT_BY_START_TIMESTAMP_ASC = Sort.by(Sort.Direction.ASC, "startTimestamp"); + // ==================== Board Config ==================== - // ==================== Board Config Operations ==================== - - /** - * Get the board configuration for a specific board location. - */ public Optional getBoardConfig(BoardLocation boardLocation) { return boardConfigRepository.findById(boardLocation); } - /** - * Get board config or throw if not found. - */ public BoardConfig getBoardConfigOrThrow(BoardLocation boardLocation) { return boardConfigRepository.findById(boardLocation) .orElseThrow(() -> new ApiResponseException( @@ -55,203 +46,163 @@ public BoardConfig getBoardConfigOrThrow(BoardLocation boardLocation) { ErrorResponse.of("Board configuration not found for location: " + boardLocation))); } - /** - * Save or update a board configuration. - */ public BoardConfig saveBoardConfig(BoardConfig boardConfig) { BoardConfig saved = boardConfigRepository.save(boardConfig); log.info("Saved board config for location: {}", boardConfig.getBoardLocation()); return saved; } - /** - * Update an existing board configuration with partial updates. - */ public BoardConfig updateBoardConfig(BoardLocation boardLocation, UpdateBoardConfigRequest request) { BoardConfig existing = getBoardConfigOrThrow(boardLocation); - Patch.apply(request.getLocation(), existing::setLocation); - Patch.apply(request.getPosterCycleInterval(), existing::setPosterCycleInterval); - Patch.apply(request.getRefreshAfterIshaaMinutes(), existing::setRefreshAfterIshaaMinutes); + Patch.apply(request.getPosterCycleIntervalMs(), existing::setPosterCycleIntervalMs); + Patch.apply(request.getRefreshAfterIshaMinutes(), existing::setRefreshAfterIshaMinutes); Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); - Patch.apply(request.getDarkModeMinutesAfterIsha(), existing::setDarkModeMinutesAfterIsha); + Patch.apply(request.getDarkModeAfterIshaMinutes(), existing::setDarkModeAfterIshaMinutes); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); - BoardConfig saved = boardConfigRepository.save(existing); log.info("Updated board config for location: {}", boardLocation); return saved; } - /** - * Get all board configurations. - */ public List getAllBoardConfigs() { return boardConfigRepository.findAll(); } - // ==================== Weekly Content Operations ==================== + // ==================== Weekly Content ==================== - /** - * Get all weekly content. - */ public List getAllWeeklyContent() { return weeklyContentRepository.findAll(); } - /** - * Get weekly content for a specific week. - */ - public Optional getWeeklyContent(WeekId weekId) { - return weeklyContentRepository.findById(weekId); - } - - /** - * Get weekly content for a specific year and week number. - */ public Optional getWeeklyContent(int year, int weekNumber) { - return weeklyContentRepository.findById(new WeekId(year, weekNumber)); + return weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber); } - /** - * Get weekly content or throw if not found. - */ public WeeklyContent getWeeklyContentOrThrow(int year, int weekNumber) { - WeekId weekId = new WeekId(year, weekNumber); - return weeklyContentRepository.findById(weekId) + return weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); } - /** - * Get the current week's content. - */ public Optional getCurrentWeeklyContent() { - WeekId currentWeek = WeekId.fromDate(LocalDate.now()); - return weeklyContentRepository.findById(currentWeek); + WeekId current = WeekId.fromDate(LocalDate.now()); + return weeklyContentRepository.findByYearAndWeekNumber(current.getYear(), current.getWeekNumber()); } - /** - * Get weekly content for a specific year. - */ public List getWeeklyContentByYear(int year) { - return weeklyContentRepository.findByWeekIdYear(year); - } - - /** - * Create or update weekly content. - */ - public WeeklyContent saveWeeklyContent(WeeklyContentRequest request) { - WeekId weekId = new WeekId(request.getYear(), request.getWeekNumber()); - - WeeklyContent content = weeklyContentRepository.findById(weekId) - .orElse(WeeklyContent.builder().weekId(weekId).build()); + return weeklyContentRepository.findByYear(year); + } + + @Transactional + public WeeklyContent saveWeeklyContent(int year, int weekNumber, WeeklyContentRequest request) { + WeeklyContent content = weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) + .orElseGet(() -> WeeklyContent.builder().year(year).weekNumber(weekNumber).build()); + + if (request.getQuotes() != null) { + content.getQuotes().clear(); + for (WeeklyContentRequest.QuoteEntry entry : request.getQuotes()) { + IslamicQuote quote = IslamicQuote.builder() + .weeklyContent(content) + .kind(entry.kind()) + .arabic(entry.arabic()) + .transliteration(entry.transliteration()) + .translation(entry.translation()) + .reference(entry.reference()) + .build(); + content.getQuotes().add(quote); + } + } - Patch.apply(request.getVerse(), content::setVerse); - Patch.apply(request.getHadith(), content::setHadith); - Patch.apply(request.getJummahPrayer(), content::setJummahPrayer); + if (request.getJummahPrayers() != null) { + content.getJummahPrayers().clear(); + for (WeeklyContentRequest.JummahSlot slot : request.getJummahPrayers()) { + JummahPrayer prayer = JummahPrayer.builder() + .weeklyContent(content) + .prayerTime(slot.prayerTime() != null + ? java.time.LocalTime.parse(slot.prayerTime(), DateTimeFormatter.ofPattern("HH:mm")) + : null) + .khatib(slot.khatib()) + .room(slot.room()) + .build(); + content.getJummahPrayers().add(prayer); + } + } WeeklyContent saved = weeklyContentRepository.save(content); - log.info("Saved weekly content for week {} of {}", request.getWeekNumber(), request.getYear()); + log.info("Saved weekly content for week {} of {}", weekNumber, year); return saved; } - /** - * Delete weekly content. - */ public void deleteWeeklyContent(int year, int weekNumber) { - WeekId weekId = new WeekId(year, weekNumber); - if (!weeklyContentRepository.existsById(weekId)) { - throw new ApiResponseException( - HttpStatus.NOT_FOUND, - ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year)); - } - weeklyContentRepository.deleteById(weekId); + WeeklyContent content = weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); + weeklyContentRepository.delete(content); log.info("Deleted weekly content for week {} of {}", weekNumber, year); } - // ==================== Event Operations ==================== + // ==================== Events ==================== - /** - * Get all events. - */ public List getAllEvents() { - return eventRepository.findAllByOrderByStartTimestampAsc(); + return boardEventRepository.findAllByOrderByStartEpochMsAsc(); } - /** - * Get an event by ID. - */ public Event getEventById(UUID eventId) { - return eventRepository.findById(eventId) + return boardEventRepository.findById(eventId) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Event not found with id: " + eventId))); } - /** - * Get all events for a specific board location. - * Returns events that match the board's audience or BOTH. - */ public List getEventsForBoard(BoardLocation boardLocation) { - return eventRepository.findByAudienceOrBoth(boardLocation.audience(), SORT_BY_START_TIMESTAMP_ASC); + return boardEventRepository.findByAudienceOrBoth(boardLocation.audience()); } - /** - * Get upcoming events for a specific board location. - * Returns events with startTimestamp >= now. - */ public List getUpcomingEventsForBoard(BoardLocation boardLocation) { - long nowTimestamp = Instant.now().toEpochMilli(); - return eventRepository.findUpcomingByAudienceOrBoth(boardLocation.audience(), nowTimestamp, SORT_BY_START_TIMESTAMP_ASC); + long now = Instant.now().toEpochMilli(); + return boardEventRepository.findUpcomingByAudienceOrBoth(boardLocation.audience(), now); } - /** - * Get events for a specific board within a time range. - */ public List getEventsForBoardInRange(BoardLocation boardLocation, long rangeStart, long rangeEnd) { - return eventRepository.findOverlappingForAudienceOrBoth(boardLocation.audience(), rangeStart, rangeEnd, SORT_BY_START_TIMESTAMP_ASC); - } - - /** - * Create a new event. - */ - public Event createEvent(Event event) { - if (event.getId() == null) { - event.setId(UUID.randomUUID()); - } - Event saved = eventRepository.save(event); + return boardEventRepository.findOverlappingForAudienceOrBoth(boardLocation.audience(), rangeStart, rangeEnd); + } + + public Event createEvent(CreateCalendarEventRequest request) { + Event event = Event.builder() + .name(request.getName()) + .description(request.getDescription()) + .location(request.getLocation()) + .startEpochMs(request.getStartEpochMs()) + .endEpochMs(request.getEndEpochMs()) + .allDay(request.getAllDay()) + .audience(request.getAudience()) + .build(); + Event saved = boardEventRepository.save(event); log.info("Created event: id={}, name={}", saved.getId(), saved.getName()); return saved; } - /** - * Update an existing event. - */ - public Event updateEvent(UUID eventId, Event updates) { + public Event updateEvent(UUID eventId, UpdateCalendarEventRequest request) { Event existing = getEventById(eventId); - - Patch.apply(updates.getName(), existing::setName); - Patch.apply(updates.getDescription(), existing::setDescription); - Patch.apply(updates.getLocation(), existing::setLocation); - Patch.apply(updates.getStartTimestamp(), existing::setStartTimestamp); - Patch.apply(updates.getEndTimestamp(), existing::setEndTimestamp); - Patch.apply(updates.getAllDay(), existing::setAllDay); - Patch.apply(updates.getAudience(), existing::setAudience); - - Event saved = eventRepository.save(existing); + Patch.apply(request.getName(), existing::setName); + Patch.apply(request.getDescription(), existing::setDescription); + Patch.apply(request.getLocation(), existing::setLocation); + Patch.apply(request.getStartEpochMs(), existing::setStartEpochMs); + Patch.apply(request.getEndEpochMs(), existing::setEndEpochMs); + Patch.apply(request.getAllDay(), existing::setAllDay); + Patch.apply(request.getAudience(), existing::setAudience); + Event saved = boardEventRepository.save(existing); log.info("Updated event: id={}", eventId); return saved; } - /** - * Delete an event. - */ public void deleteEvent(UUID eventId) { Event event = getEventById(eventId); - eventRepository.delete(event); + boardEventRepository.delete(event); log.info("Deleted event: id={}", eventId); } - } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index 618bd09..73c8ad6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -9,11 +9,10 @@ import com.ibrasoft.lensbridge.service.board.BoardContext; import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; import com.ibrasoft.lensbridge.util.Patch; -import com.ibrasoft.lensbridge.repository.PosterRepository; +import com.ibrasoft.lensbridge.repository.sql.PosterRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -36,8 +35,6 @@ public class PosterService { @Value("${cloudflare.r2.public-url}") private String publicUrl; - private static final Sort SORT_BY_START_DATE_DESC = Sort.by(Sort.Direction.DESC, "startDate"); - /** * Get all posters, sorted by startDate descending (newest first). */ @@ -61,7 +58,7 @@ public Poster getPosterById(UUID posterId) { * Returns posters that match the board's audience or BOTH. */ public List getPostersForBoard(BoardLocation boardLocation) { - return posterRepository.findByAudienceOrBoth(boardLocation.audience(), SORT_BY_START_DATE_DESC); + return posterRepository.findByAudienceOrBoth(boardLocation.audience()); } /** @@ -71,7 +68,7 @@ public List getPostersForBoard(BoardLocation boardLocation) { */ public List getActivePosterFramesForBoard(BoardLocation boardLocation) { LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience(), SORT_BY_START_DATE_DESC); + return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience()); } /** @@ -103,7 +100,6 @@ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) } Poster poster = Poster.builder() - .id(UUID.randomUUID()) .title(request.getTitle()) .image(publicUrl + "/" + objectKey) .duration(request.getDuration()) @@ -216,7 +212,7 @@ public void deletePoster(UUID posterId) { */ public List getActivePosterFrameDefinitions(BoardLocation boardLocation) { LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience(), SORT_BY_START_DATE_DESC) + return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience()) .stream() .map(this::toFrameDefinition) .collect(Collectors.toList()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java index 8b74763..64f184c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.service.board.transformer; +import com.ibrasoft.lensbridge.dto.response.frames.EventView; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; @@ -26,7 +26,7 @@ public FrameType supports() { @Override public FrameDefinition transform(List events, BoardContext ctx) { - List eventViews = events.stream() + List eventViews = events.stream() .map(this::toEventView) .collect(Collectors.toList()); @@ -44,13 +44,13 @@ public FrameDefinition transform(List events, BoardContext ctx) { .build(); } - private EventListFrameConfig.EventView toEventView(Event event) { - return EventListFrameConfig.EventView.builder() + private EventView toEventView(Event event) { + return EventView.builder() .name(event.getName()) .description(event.getDescription()) .location(event.getLocation()) - .startTimestamp(event.getStartTimestamp()) - .endTimestamp(event.getEndTimestamp()) + .startEpochMs(event.getStartEpochMs()) + .endEpochMs(event.getEndEpochMs()) .allDay(event.getAllDay()) .build(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java index d7d2aa4..3e01f44 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.service.board.transformer; +import com.ibrasoft.lensbridge.dto.response.frames.EventView; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; @@ -25,7 +26,7 @@ public FrameType supports() { @Override public FrameDefinition transform(List events, BoardContext ctx) { - List eventViews = events.stream() + List eventViews = events.stream() .map(this::toEventView) .collect(Collectors.toList()); @@ -43,13 +44,13 @@ public FrameDefinition transform(List events, BoardContext ctx) { .build(); } - private EventListFrameConfig.EventView toEventView(Event event) { - return EventListFrameConfig.EventView.builder() + private EventView toEventView(Event event) { + return EventView.builder() .name(event.getName()) .description(event.getDescription()) .location(event.getLocation()) - .startTimestamp(event.getStartTimestamp()) - .endTimestamp(event.getEndTimestamp()) + .startEpochMs(event.getStartEpochMs()) + .endEpochMs(event.getEndEpochMs()) .allDay(event.getAllDay()) .build(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java index f3a3966..d903bea 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java @@ -16,12 +16,11 @@ import java.util.stream.Collectors; /** - * Transforms a WeeklyContent document into up to three FrameDefinitions: - * one for the verse, one for the hadith, and one for jummah prayer info. + * Transforms a WeeklyContent entity into FrameDefinitions: + * one per quote (VERSE/HADITH kind) and one for jummah prayer info. * * This transformer intentionally does NOT implement FrameTransformer because - * one source expands to multiple frames. The assembler (Phase 4) will inject it - * by concrete class. + * one source expands to multiple frames. The assembler injects it by concrete class. */ @Component public class WeeklyContentFrameTransformer { @@ -30,15 +29,17 @@ public List transform(WeeklyContent content, BoardContext ctx) List out = new ArrayList<>(); if (content == null) return out; - if (content.getVerse() != null) { - out.add(quoteFrame(content.getVerse(), IslamicQuoteFrameConfig.Kind.VERSE)); + for (IslamicQuote quote : content.getQuotes()) { + IslamicQuoteFrameConfig.Kind kind = quote.getKind() == IslamicQuote.Kind.VERSE + ? IslamicQuoteFrameConfig.Kind.VERSE + : IslamicQuoteFrameConfig.Kind.HADITH; + out.add(quoteFrame(quote, kind)); } - if (content.getHadith() != null) { - out.add(quoteFrame(content.getHadith(), IslamicQuoteFrameConfig.Kind.HADITH)); - } - if (content.getJummahPrayer() != null && !content.getJummahPrayer().isEmpty()) { - out.add(jummahFrame(content.getJummahPrayer())); + + if (!content.getJummahPrayers().isEmpty()) { + out.add(jummahFrame(content.getJummahPrayers())); } + return out; } @@ -65,7 +66,7 @@ private FrameDefinition jummahFrame(List prayers) { .map(p -> JummahFrameConfig.JummahSlot.builder() .prayerTime(p.getPrayerTime() != null ? p.getPrayerTime().toString() : null) .khatib(p.getKhatib()) - .location(p.getLocation()) + .room(p.getRoom()) .build()) .collect(Collectors.toList()); From c154be0a7bca5a841782d3460afde597a0bad368 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sat, 9 May 2026 00:14:10 -0400 Subject: [PATCH 02/52] feat(agent): device control plane foundation - entities, enrollment, Ed25519 Adds the persistence layer and onboarding handshake for the new agent control plane that lets the admin dashboard run commands on each Pi. Layer 1 - entities + repos: - Device extended with publicKey, agentVersion, hardwareModel, lastSeenIp, revokedAt - EnrollmentToken: one-time admin-issued pairing token, SHA-256 hash stored, plaintext shown once - DeviceTelemetry: append-only heartbeat history - DeviceCommand + DeviceCommandStatus: audit log + delivery queue with state machine (PENDING -> DELIVERED -> ACKED -> RUNNING -> terminal) - DeviceRepository.findByRevokedAtIsNullAndLastHeartbeatBefore for offline detection Layer 2 - Ed25519 + enrollment: - Ed25519Verifier wraps JDK 17+ native Ed25519; raw 32-byte public keys wrapped to X.509 SubjectPublicKeyInfo at verify time - EnrollmentTokenService issues 144-bit base64 tokens, atomic single-consume with expiry check, optional pepper - DeviceEnrollmentService orchestrates token consume + Device row creation with public key - POST /api/agent/enroll (public, token-authenticated) for agent side; admin endpoints under /api/admin/board/devices for token issuance, list, revoke - WebSecurityConfig permits /api/agent/enroll - 12 unit tests (Ed25519 round-trip + tamper + wrong-key + bad-input; token issue + consume happy/expired/used/unknown/blank) Co-Authored-By: Claude Opus 4.7 --- .../controller/AgentEnrollmentController.java | 71 +++++++++++ .../controller/DeviceAdminController.java | 92 ++++++++++++++ .../dto/request/AgentEnrollRequest.java | 22 ++++ .../request/IssueEnrollmentTokenRequest.java | 21 ++++ .../dto/response/AgentEnrollResponse.java | 16 +++ .../dto/response/DeviceSummary.java | 41 +++++++ .../IssueEnrollmentTokenResponse.java | 23 ++++ .../lensbridge/model/board/Device.java | 55 +++++++++ .../lensbridge/model/board/DeviceCommand.java | 75 ++++++++++++ .../model/board/DeviceCommandStatus.java | 34 ++++++ .../model/board/DeviceTelemetry.java | 51 ++++++++ .../model/board/EnrollmentToken.java | 60 ++++++++++ .../sql/DeviceCommandRepository.java | 19 +++ .../repository/sql/DeviceRepository.java | 19 +++ .../sql/DeviceTelemetryRepository.java | 22 ++++ .../sql/EnrollmentTokenRepository.java | 14 +++ .../security/WebSecurityConfig.java | 1 + .../agent/DeviceEnrollmentService.java | 84 +++++++++++++ .../service/agent/EnrollmentTokenService.java | 108 +++++++++++++++++ .../agent/handshake/Ed25519Verifier.java | 60 ++++++++++ .../service/agent/Ed25519VerifierTest.java | 113 ++++++++++++++++++ .../agent/EnrollmentTokenServiceTest.java | 110 +++++++++++++++++ 22 files changed, 1111 insertions(+) create mode 100644 src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/Device.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519VerifierTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenServiceTest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java new file mode 100644 index 0000000..13c7472 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -0,0 +1,71 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.dto.request.AgentEnrollRequest; +import com.ibrasoft.lensbridge.dto.response.AgentEnrollResponse; +import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; +import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Public endpoint used by a freshly-flashed agent to convert a one-time enrollment token + * into a Device row + Ed25519 public-key registration. + *

+ * No auth required (the token is the credential), but rate-limiting belongs at the gateway. + */ +@RestController +@RequestMapping("/api/agent") +@RequiredArgsConstructor +@Slf4j +public class AgentEnrollmentController { + + private final DeviceEnrollmentService enrollmentService; + + @Value("${musallahboard.agent.websocketUrl:}") + private String websocketUrl; + + @PostMapping("/enroll") + public ResponseEntity enroll(@Valid @RequestBody AgentEnrollRequest request, + HttpServletRequest httpRequest) { + String remoteIp = resolveClientIp(httpRequest); + + Outcome outcome = enrollmentService.enroll( + request.getToken(), + request.getPublicKey(), + request.getHostname(), + request.getHardwareModel(), + request.getAgentVersion(), + remoteIp + ); + + return switch (outcome) { + case Outcome.Ok ok -> ResponseEntity.ok(AgentEnrollResponse.builder() + .deviceId(ok.device().getId()) + .websocketUrl(websocketUrl) + .build()); + case Outcome.InvalidPublicKey bad -> ResponseEntity.badRequest() + .body(new MessageResponse("Invalid public key: " + bad.reason())); + case Outcome.InvalidToken ignored -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new MessageResponse("Enrollment token is invalid, expired, or already used")); + }; + } + + private static String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + int comma = forwarded.indexOf(','); + return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java new file mode 100644 index 0000000..f55e833 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -0,0 +1,92 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.dto.request.IssueEnrollmentTokenRequest; +import com.ibrasoft.lensbridge.dto.response.DeviceSummary; +import com.ibrasoft.lensbridge.dto.response.IssueEnrollmentTokenResponse; +import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; +import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService.Issued; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/board/devices") +@RequiredArgsConstructor +@PreAuthorize("hasRole('" + Role.ROOT + "')") +@Slf4j +public class DeviceAdminController { + + private final EnrollmentTokenService enrollmentTokenService; + private final DeviceRepository deviceRepository; + + @GetMapping + public ResponseEntity> list() { + return ResponseEntity.ok( + deviceRepository.findAll().stream().map(DeviceSummary::of).toList() + ); + } + + @GetMapping("/{deviceId}") + public ResponseEntity get(@PathVariable UUID deviceId) { + Optional found = deviceRepository.findById(deviceId); + return found.>map(d -> ResponseEntity.ok(DeviceSummary.of(d))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new MessageResponse("Device not found"))); + } + + @PostMapping("/enrollment-tokens") + public ResponseEntity issueEnrollmentToken( + @Valid @RequestBody IssueEnrollmentTokenRequest request) { + UserDetailsImpl admin = currentUser(); + Issued issued = enrollmentTokenService.issue( + request.getDisplayName(), + request.getAudience(), + request.getExpiresInMinutes(), + admin.getEmail() + ); + log.info("Admin {} issued enrollment token {} ({})", + admin.getEmail(), issued.token().getId(), request.getDisplayName()); + return ResponseEntity.status(HttpStatus.CREATED).body( + IssueEnrollmentTokenResponse.builder() + .tokenId(issued.token().getId()) + .token(issued.plaintext()) + .expiresAt(issued.token().getExpiresAt()) + .build() + ); + } + + @PostMapping("/{deviceId}/revoke") + public ResponseEntity revoke(@PathVariable UUID deviceId) { + Optional found = deviceRepository.findById(deviceId); + if (found.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new MessageResponse("Device not found")); + } + Device d = found.get(); + if (d.getRevokedAt() == null) { + d.setRevokedAt(Instant.now()); + deviceRepository.save(d); + log.warn("Device {} revoked by {}", deviceId, currentUser().getEmail()); + } + return ResponseEntity.ok(DeviceSummary.of(d)); + } + + private UserDetailsImpl currentUser() { + return (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java new file mode 100644 index 0000000..f048140 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class AgentEnrollRequest { + + /** Plaintext one-time token issued by an admin. */ + @NotBlank + private String token; + + /** Base64-encoded 32-byte Ed25519 public key generated on the device. */ + @NotBlank + private String publicKey; + + @NotBlank + private String hostname; + + private String hardwareModel; + private String agentVersion; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java new file mode 100644 index 0000000..80c67ab --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java @@ -0,0 +1,21 @@ +package com.ibrasoft.lensbridge.dto.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Data +public class IssueEnrollmentTokenRequest { + + @NotBlank + private String displayName; + + @NotNull + private Audience audience; + + /** Lifetime of the token in minutes. Server clamps to a sane range. */ + @Positive + private Integer expiresInMinutes; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java new file mode 100644 index 0000000..179ef20 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java @@ -0,0 +1,16 @@ +package com.ibrasoft.lensbridge.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class AgentEnrollResponse { + + private UUID deviceId; + + /** Absolute URL the agent should connect to for the persistent command channel. */ + private String websocketUrl; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java new file mode 100644 index 0000000..3210202 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.dto.response; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.UUID; + +/** + * Admin-facing summary of a Device. Excludes the public key bytes (operators don't need them). + */ +@Data +@Builder +public class DeviceSummary { + + private UUID id; + private String displayName; + private Audience audience; + private Instant enrolledAt; + private Instant lastHeartbeat; + private Instant revokedAt; + private String agentVersion; + private String hardwareModel; + private String lastSeenIp; + + public static DeviceSummary of(Device d) { + return DeviceSummary.builder() + .id(d.getId()) + .displayName(d.getDisplayName()) + .audience(d.getAudience()) + .enrolledAt(d.getEnrolledAt()) + .lastHeartbeat(d.getLastHeartbeat()) + .revokedAt(d.getRevokedAt()) + .agentVersion(d.getAgentVersion()) + .hardwareModel(d.getHardwareModel()) + .lastSeenIp(d.getLastSeenIp()) + .build(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java new file mode 100644 index 0000000..6a80d06 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java @@ -0,0 +1,23 @@ +package com.ibrasoft.lensbridge.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.UUID; + +/** + * Returned exactly once when an admin issues a new enrollment token. + * The plaintext token is shown to the operator and never persisted. + */ +@Data +@Builder +public class IssueEnrollmentTokenResponse { + + private UUID tokenId; + + /** Human-typeable plaintext (base32, dash-separated). Show once, never store. */ + private String token; + + private Instant expiresAt; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java new file mode 100644 index 0000000..57bc445 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java @@ -0,0 +1,55 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "devices") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Device { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + // TODO: If you ever add multi-tenancy, actually use this + // Hardcoded for now because we only have one organization + private String organizationId = "utmmsa"; + @NotBlank + private String displayName; + + private Instant enrolledAt; + private Instant lastHeartbeat; + + @OneToOne(mappedBy = "device", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private DeviceConfig config; + + private Audience audience; + + /** Ed25519 public key (32 bytes). Set during enrollment; null before. */ + @Column(name = "public_key", length = 32) + private byte[] publicKey; + + private String agentVersion; + private String hardwareModel; + private String lastSeenIp; + + /** When set, all auth attempts are rejected and any live session is closed. */ + private Instant revokedAt; + + @PrePersist + private void prePersist() { + enrolledAt = Instant.now(); + } + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java new file mode 100644 index 0000000..ead6209 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java @@ -0,0 +1,75 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Audit log + delivery queue for admin-issued commands targeted at a single device. + *

+ * The polymorphic command shape (kind + payload) is captured here as a discriminator + * string and a JSON blob, so adding new command kinds does not require schema changes. + */ +@Entity +@Table(name = "device_commands", indexes = { + @Index(name = "idx_device_commands_device_status", + columnList = "device_id, status"), + @Index(name = "idx_device_commands_pending", + columnList = "device_id, issued_at") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceCommand { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "device_id", nullable = false) + private UUID deviceId; + + /** Polymorphic discriminator (e.g. "chrome.reload"). */ + @Column(nullable = false) + private String kind; + + /** Serialized CommandPayload subclass (Jackson). */ + @Column(columnDefinition = "TEXT") + private String payloadJson; + + /** Username of the admin that issued the command (from JWT). */ + @Column(nullable = false) + private String issuedBy; + + /** Maximum execution time as supplied by the issuer; null = server default. */ + private Integer deadlineMs; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DeviceCommandStatus status; + + @Column(nullable = false) + private Instant issuedAt; + + private Instant deliveredAt; + private Instant ackedAt; + private Instant startedAt; + private Instant finishedAt; + + /** Free-form result payload from the agent (JSON). */ + @Column(columnDefinition = "TEXT") + private String outputJson; + + /** Short error message when status is FAILED / TIMEOUT / REJECTED / EXPIRED. */ + @Column(columnDefinition = "TEXT") + private String errorMessage; + + @PrePersist + private void prePersist() { + if (issuedAt == null) issuedAt = Instant.now(); + if (status == null) status = DeviceCommandStatus.PENDING; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java new file mode 100644 index 0000000..fcd147f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommandStatus.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.model.board; + +/** + * State machine for an admin-issued device command. + *

+ * Terminal states: SUCCEEDED, FAILED, TIMEOUT, REJECTED, EXPIRED. + */ +public enum DeviceCommandStatus { + /** Persisted, awaiting an authenticated agent session. */ + PENDING, + /** Frame written to the agent's WS session. */ + DELIVERED, + /** Agent acknowledged receipt and accepted the command. */ + ACKED, + /** Agent reported it has begun executing. */ + RUNNING, + /** Agent finished successfully. */ + SUCCEEDED, + /** Agent reported a runtime error. */ + FAILED, + /** Agent (or backend) hit the deadline before completion. */ + TIMEOUT, + /** Agent refused the command (e.g. unknown kind). */ + REJECTED, + /** Backend gave up waiting for a session before the command could be delivered. */ + EXPIRED; + + public boolean isTerminal() { + return switch (this) { + case SUCCEEDED, FAILED, TIMEOUT, REJECTED, EXPIRED -> true; + default -> false; + }; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java new file mode 100644 index 0000000..395dd42 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java @@ -0,0 +1,51 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Append-only telemetry sample emitted by an agent over its WebSocket session. + * Retention is bounded by a periodic prune (default 30 days). + */ +@Entity +@Table(name = "device_telemetry", indexes = { + @Index(name = "idx_device_telemetry_device_recorded", + columnList = "device_id, recorded_at") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceTelemetry { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "device_id", nullable = false) + private UUID deviceId; + + @Column(name = "recorded_at", nullable = false) + private Instant recordedAt; + + private Long uptimeSec; + private Double cpuTempC; + private String throttleFlags; + + private Integer memUsedMb; + private Integer memTotalMb; + private Integer diskUsedPct; + + private Boolean kioskAlive; + + /** Comma-separated list of non-loopback IPv4 addresses. */ + private String ipv4; + + private String wifiSsid; + + /** UUID of the FrameDefinition currently on screen, if known. */ + private UUID displayedFrameId; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java b/src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java new file mode 100644 index 0000000..b6a42a8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/EnrollmentToken.java @@ -0,0 +1,60 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * One-time pairing token issued by an admin and consumed by an agent during enrollment. + * Plaintext is shown to the operator once at issue time; only its SHA-256 hash is persisted. + */ +@Entity +@Table(name = "enrollment_tokens", indexes = { + @Index(name = "idx_enrollment_tokens_hash", columnList = "token_hash", unique = true) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EnrollmentToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** SHA-256 of the plaintext token (32 bytes). */ + @Column(name = "token_hash", length = 32, nullable = false) + private byte[] tokenHash; + + /** Display name to assign to the resulting Device. */ + @Column(nullable = false) + private String displayName; + + /** Audience baked in at issue time so the operator can't typo it on the Pi. */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Audience audience; + + /** Username of the admin that issued the token (from JWT). */ + @Column(nullable = false) + private String createdBy; + + @Column(nullable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant expiresAt; + + /** Set the moment the token is consumed; subsequent attempts fail. */ + private Instant consumedAt; + + /** Device created from this token, if consumed. */ + private UUID consumedByDeviceId; + + @PrePersist + private void prePersist() { + if (createdAt == null) createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java new file mode 100644 index 0000000..9a79aa7 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java @@ -0,0 +1,19 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface DeviceCommandRepository extends JpaRepository { + + /** Pending commands for a device, oldest first — used to flush the queue on session auth. */ + List findByDeviceIdAndStatusOrderByIssuedAtAsc(UUID deviceId, DeviceCommandStatus status); + + /** Recent command history for a device (any status), newest first. */ + List findTop50ByDeviceIdOrderByIssuedAtDesc(UUID deviceId); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java new file mode 100644 index 0000000..066e03c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceRepository.java @@ -0,0 +1,19 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Device; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface DeviceRepository extends JpaRepository { + + /** Devices whose last heartbeat is older than the cutoff (or never reported). Used by the offline-detection cron. */ + List findByRevokedAtIsNullAndLastHeartbeatBefore(Instant cutoff); + + /** Devices that have been enrolled but never reported a heartbeat. */ + List findByRevokedAtIsNullAndLastHeartbeatIsNull(); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java new file mode 100644 index 0000000..d2897c0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceTelemetryRepository.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface DeviceTelemetryRepository extends JpaRepository { + + List findByDeviceIdOrderByRecordedAtDesc(UUID deviceId); + + @Modifying + @Query("DELETE FROM DeviceTelemetry t WHERE t.recordedAt < :cutoff") + int deleteOlderThan(@Param("cutoff") Instant cutoff); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java new file mode 100644 index 0000000..50d6c0c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/EnrollmentTokenRepository.java @@ -0,0 +1,14 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface EnrollmentTokenRepository extends JpaRepository { + + Optional findByTokenHash(byte[] tokenHash); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index 3f680b4..aff851c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -87,6 +87,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/events/**").permitAll() .requestMatchers("/api/musallah/**").permitAll() .requestMatchers("/api/refresh-musallahboard").permitAll() + .requestMatchers("/api/agent/enroll").permitAll() .anyRequest().authenticated()); http.authenticationProvider(authenticationProvider()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java new file mode 100644 index 0000000..42ac2a0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java @@ -0,0 +1,84 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +/** + * Orchestrates the agent enrollment flow: + *

    + *
  1. Validate and decode the device-supplied Ed25519 public key.
  2. + *
  3. Atomically consume the one-time enrollment token.
  4. + *
  5. Persist a Device row carrying the public key and metadata reported by the agent.
  6. + *
+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DeviceEnrollmentService { + + private static final int ED25519_PUBLIC_KEY_LEN = 32; + + private final EnrollmentTokenService enrollmentTokenService; + private final DeviceRepository deviceRepository; + + public record EnrollResult(Device device) {} + + public sealed interface Outcome { + record Ok(Device device) implements Outcome {} + record InvalidPublicKey(String reason) implements Outcome {} + record InvalidToken() implements Outcome {} + } + + @Transactional + public Outcome enroll(String plaintextToken, + String publicKeyBase64, + String hostname, + String hardwareModel, + String agentVersion, + String remoteIp) { + byte[] publicKey; + try { + publicKey = Base64.getDecoder().decode(publicKeyBase64); + } catch (IllegalArgumentException e) { + return new Outcome.InvalidPublicKey("not base64"); + } + if (publicKey.length != ED25519_PUBLIC_KEY_LEN) { + return new Outcome.InvalidPublicKey("expected 32 bytes, got " + publicKey.length); + } + + UUID provisionalDeviceId = UUID.randomUUID(); + Optional consumed = enrollmentTokenService.consume(plaintextToken, provisionalDeviceId); + if (consumed.isEmpty()) { + return new Outcome.InvalidToken(); + } + EnrollmentToken token = consumed.get(); + + Device device = Device.builder() + .id(provisionalDeviceId) + .displayName(hostname != null && !hostname.isBlank() + ? token.getDisplayName() + " (" + hostname + ")" + : token.getDisplayName()) + .audience(token.getAudience()) + .publicKey(publicKey) + .hardwareModel(hardwareModel) + .agentVersion(agentVersion) + .lastSeenIp(remoteIp) + .enrolledAt(Instant.now()) + .build(); + + Device saved = deviceRepository.save(device); + log.info("Enrolled device {} from token {} (issued by {})", + saved.getId(), token.getId(), token.getCreatedBy()); + return new Outcome.Ok(saved); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java new file mode 100644 index 0000000..2aab2e2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenService.java @@ -0,0 +1,108 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.repository.sql.EnrollmentTokenRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +/** + * Issues and consumes one-time enrollment tokens. + *

+ * The plaintext token is returned to the admin once at issue time and is never persisted — + * we only store its SHA-256 hash. Consumption is single-attempt: a successful consume marks + * the row before the caller is told "ok," so two concurrent agents racing on the same token + * cannot both win. + */ +@Service +@RequiredArgsConstructor +public class EnrollmentTokenService { + + /** 18 random bytes → 24 chars of url-safe base64 with no padding. ~144 bits of entropy. */ + private static final int TOKEN_RANDOM_BYTES = 18; + + private static final int DEFAULT_TTL_MINUTES = 30; + private static final int MAX_TTL_MINUTES = 60 * 24; + + private final EnrollmentTokenRepository repository; + private final SecureRandom random = new SecureRandom(); + + @Value("${musallahboard.enrollment.tokenPepper:}") + private String pepper; + + public record Issued(EnrollmentToken token, String plaintext) {} + + @Transactional + public Issued issue(String displayName, Audience audience, Integer ttlMinutes, String createdBy) { + int ttl = clampTtl(ttlMinutes); + String plaintext = generatePlaintext(); + + EnrollmentToken row = EnrollmentToken.builder() + .tokenHash(hash(plaintext)) + .displayName(displayName) + .audience(audience) + .createdBy(createdBy) + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus(Duration.ofMinutes(ttl))) + .build(); + + EnrollmentToken saved = repository.save(row); + return new Issued(saved, plaintext); + } + + /** + * Atomically marks a token as consumed iff it is unconsumed and unexpired. + * + * @return the consumed row on success, empty if the token is unknown / expired / already used + */ + @Transactional + public Optional consume(String plaintext, UUID consumedByDeviceId) { + if (plaintext == null || plaintext.isBlank()) return Optional.empty(); + + Optional found = repository.findByTokenHash(hash(plaintext)); + if (found.isEmpty()) return Optional.empty(); + + EnrollmentToken row = found.get(); + Instant now = Instant.now(); + if (row.getConsumedAt() != null) return Optional.empty(); + if (row.getExpiresAt().isBefore(now)) return Optional.empty(); + + row.setConsumedAt(now); + row.setConsumedByDeviceId(consumedByDeviceId); + return Optional.of(repository.save(row)); + } + + private int clampTtl(Integer requested) { + if (requested == null) return DEFAULT_TTL_MINUTES; + return Math.min(MAX_TTL_MINUTES, Math.max(1, requested)); + } + + private String generatePlaintext() { + byte[] raw = new byte[TOKEN_RANDOM_BYTES]; + random.nextBytes(raw); + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + } + + byte[] hash(String plaintext) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + if (pepper != null && !pepper.isEmpty()) { + digest.update(pepper.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + return digest.digest(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java new file mode 100644 index 0000000..b1d7e87 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/Ed25519Verifier.java @@ -0,0 +1,60 @@ +package com.ibrasoft.lensbridge.service.agent.handshake; + +import org.springframework.stereotype.Component; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; + +/** + * Verifies Ed25519 signatures using the JDK 17+ native provider. + *

+ * Public keys are persisted as the raw 32-byte Ed25519 point. We wrap the raw key + * into an X.509 SubjectPublicKeyInfo envelope at verification time so JCE can + * decode it. This keeps the on-the-wire and at-rest formats minimal. + */ +@Component +public class Ed25519Verifier { + + /** ASN.1 DER prefix for an Ed25519 SubjectPublicKeyInfo: AlgorithmIdentifier + BIT STRING header. */ + private static final byte[] X509_PREFIX = new byte[]{ + 0x30, 0x2a, // SEQUENCE, length 42 + 0x30, 0x05, // SEQUENCE, length 5 + 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519) + 0x03, 0x21, 0x00 // BIT STRING, length 33, 0 unused bits + }; + + private static final int RAW_PUBLIC_KEY_LEN = 32; + private static final int RAW_SIGNATURE_LEN = 64; + + /** + * @param rawPublicKey 32-byte Ed25519 public key + * @param message bytes that were signed + * @param signature 64-byte detached signature + * @return true iff the signature verifies + */ + public boolean verify(byte[] rawPublicKey, byte[] message, byte[] signature) { + if (rawPublicKey == null || rawPublicKey.length != RAW_PUBLIC_KEY_LEN) return false; + if (signature == null || signature.length != RAW_SIGNATURE_LEN) return false; + if (message == null) return false; + + try { + PublicKey publicKey = decodeRawPublicKey(rawPublicKey); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(message); + return verifier.verify(signature); + } catch (GeneralSecurityException e) { + return false; + } + } + + private static PublicKey decodeRawPublicKey(byte[] rawPublicKey) throws GeneralSecurityException { + byte[] x509 = new byte[X509_PREFIX.length + rawPublicKey.length]; + System.arraycopy(X509_PREFIX, 0, x509, 0, X509_PREFIX.length); + System.arraycopy(rawPublicKey, 0, x509, X509_PREFIX.length, rawPublicKey.length); + return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(x509)); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519VerifierTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519VerifierTest.java new file mode 100644 index 0000000..cb81f61 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519VerifierTest.java @@ -0,0 +1,113 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.Signature; +import java.security.interfaces.EdECPublicKey; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class Ed25519VerifierTest { + + private Ed25519Verifier verifier; + + @BeforeEach + void setUp() { + verifier = new Ed25519Verifier(); + } + + @Test + void verifiesGenuineSignature() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + byte[] message = "musallahboard-auth-v1\nsess\nchallenge\ndev\n123".getBytes(StandardCharsets.UTF_8); + + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(kp.getPrivate()); + signer.update(message); + byte[] sig = signer.sign(); + + byte[] rawPub = extractRawPublicKey(kp.getPublic()); + assertTrue(verifier.verify(rawPub, message, sig)); + } + + @Test + void rejectsTamperedSignature() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + byte[] message = "hello".getBytes(StandardCharsets.UTF_8); + + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(kp.getPrivate()); + signer.update(message); + byte[] sig = signer.sign(); + sig[0] ^= 0x01; + + byte[] rawPub = extractRawPublicKey(kp.getPublic()); + assertFalse(verifier.verify(rawPub, message, sig)); + } + + @Test + void rejectsSignatureWithDifferentKey() throws Exception { + KeyPair signing = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + byte[] message = "hello".getBytes(StandardCharsets.UTF_8); + + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(signing.getPrivate()); + signer.update(message); + byte[] sig = signer.sign(); + + byte[] otherPub = extractRawPublicKey(other.getPublic()); + assertFalse(verifier.verify(otherPub, message, sig)); + } + + @Test + void rejectsMalformedInputs() { + assertFalse(verifier.verify(null, new byte[0], new byte[64])); + assertFalse(verifier.verify(new byte[31], new byte[0], new byte[64])); + assertFalse(verifier.verify(new byte[32], null, new byte[64])); + assertFalse(verifier.verify(new byte[32], new byte[0], new byte[63])); + } + + /** + * JDK exposes Ed25519 public keys via EdECPublicKey.getPoint(); we need the raw 32-byte + * little-endian encoding the wire format uses. + */ + private static byte[] extractRawPublicKey(PublicKey publicKey) { + EdECPublicKey ed = (EdECPublicKey) publicKey; + byte[] y = ed.getPoint().getY().toByteArray(); + // BigInteger#toByteArray is big-endian; reverse and pad to 32 bytes little-endian. + byte[] le = new byte[32]; + for (int i = 0; i < y.length && i < 32; i++) { + le[i] = y[y.length - 1 - i]; + } + if (ed.getPoint().isXOdd()) { + le[31] |= (byte) 0x80; + } + return le; + } + + @Test + void rawKeyEncodingRoundTrips() throws Exception { + // Belt-and-suspenders: a few iterations to catch any padding / endianness mistakes. + for (int i = 0; i < 10; i++) { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + byte[] raw = extractRawPublicKey(kp.getPublic()); + assertEquals(32, raw.length); + byte[] msg = ("iter-" + i).getBytes(StandardCharsets.UTF_8); + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(kp.getPrivate()); + signer.update(msg); + byte[] sig = signer.sign(); + assertTrue(verifier.verify(raw, msg, sig), "iteration " + i); + // also confirm raw is not all-zeros + assertFalse(Arrays.equals(raw, new byte[32])); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenServiceTest.java new file mode 100644 index 0000000..ea97dbb --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/EnrollmentTokenServiceTest.java @@ -0,0 +1,110 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.repository.sql.EnrollmentTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EnrollmentTokenServiceTest { + + @Mock + private EnrollmentTokenRepository repository; + + @InjectMocks + private EnrollmentTokenService service; + + @BeforeEach + void echoSave() { + lenient().when(repository.save(any(EnrollmentToken.class))).thenAnswer(inv -> { + EnrollmentToken t = inv.getArgument(0); + if (t.getId() == null) t.setId(UUID.randomUUID()); + return t; + }); + } + + @Test + void issueProducesPlaintextAndPersistsHash() { + var issued = service.issue("Brothers Display", Audience.BROTHERS, 30, "admin@example.com"); + + assertNotNull(issued.plaintext()); + assertEquals(24, issued.plaintext().length(), "expected 24-char base64 token"); + assertNotNull(issued.token().getTokenHash()); + assertEquals(32, issued.token().getTokenHash().length, "SHA-256 hash should be 32 bytes"); + assertEquals(Audience.BROTHERS, issued.token().getAudience()); + assertEquals("admin@example.com", issued.token().getCreatedBy()); + assertTrue(issued.token().getExpiresAt().isAfter(Instant.now())); + } + + @Test + void issueClampsTtl() { + var issued = service.issue("X", Audience.BOTH, 999_999, "admin@x"); + long minutes = (issued.token().getExpiresAt().toEpochMilli() - issued.token().getCreatedAt().toEpochMilli()) / 60_000; + assertTrue(minutes <= 60 * 24, "TTL should clamp to 24h max but got " + minutes); + } + + @Test + void consumeHappyPath() { + var issued = service.issue("X", Audience.BOTH, 30, "admin@x"); + when(repository.findByTokenHash(any())).thenReturn(Optional.of(issued.token())); + + UUID deviceId = UUID.randomUUID(); + Optional consumed = service.consume(issued.plaintext(), deviceId); + + assertTrue(consumed.isPresent()); + assertNotNull(consumed.get().getConsumedAt()); + assertEquals(deviceId, consumed.get().getConsumedByDeviceId()); + } + + @Test + void consumeRejectsAlreadyUsed() { + var issued = service.issue("X", Audience.BOTH, 30, "admin@x"); + issued.token().setConsumedAt(Instant.now().minusSeconds(60)); + issued.token().setConsumedByDeviceId(UUID.randomUUID()); + when(repository.findByTokenHash(any())).thenReturn(Optional.of(issued.token())); + + Optional consumed = service.consume(issued.plaintext(), UUID.randomUUID()); + + assertTrue(consumed.isEmpty()); + verify(repository, never()).save(argThat(t -> + t != issued.token() // we don't write a fresh row + )); + } + + @Test + void consumeRejectsExpired() { + var issued = service.issue("X", Audience.BOTH, 30, "admin@x"); + issued.token().setExpiresAt(Instant.now().minusSeconds(60)); + when(repository.findByTokenHash(any())).thenReturn(Optional.of(issued.token())); + + Optional consumed = service.consume(issued.plaintext(), UUID.randomUUID()); + + assertTrue(consumed.isEmpty()); + } + + @Test + void consumeRejectsUnknown() { + when(repository.findByTokenHash(any())).thenReturn(Optional.empty()); + assertTrue(service.consume("nonsense", UUID.randomUUID()).isEmpty()); + } + + @Test + void consumeRejectsBlank() { + assertTrue(service.consume(null, UUID.randomUUID()).isEmpty()); + assertTrue(service.consume(" ", UUID.randomUUID()).isEmpty()); + verifyNoInteractions(repository); + } +} From fe90ce74470ba7d879de4f353ac290ee2cd9ad8e Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sat, 9 May 2026 00:15:58 -0400 Subject: [PATCH 03/52] refactor(board): finish device-centric migration cleanup Wraps up the PostgreSQL/normalization migration started in the prior two commits by deleting now-dead types and tightening DTOs/services around the Device-as-aggregate-root model. - Remove BoardLocation enum, StringToBoardLocationConverter, and the legacy Mongo-shaped BoardConfigRepository under repository/ in favour of the embedded Location + DeviceConfig under board/embedded - Rename BoardConfig.java -> board/embedded/DeviceConfig.java - Slim DTOs (Create/UpdatePosterRequest, Create/UpdateCalendarEventRequest, EventView, MusallahBoardPayload) - drop fields that no longer map to the JPA entities - BoardAdminController, MusallahBoardController: route everything through deviceId; drop boardLocation path params - BoardService, PosterService, BoardContext, BoardPayloadAssembler, DailyScheduleFrameTransformer, EventListFrameTransformer: align with the device-keyed lookup - Update docs/MUSALLAH_BOARD_API.md and add docs/musallahboard-api.md Co-Authored-By: Claude Opus 4.7 --- docs/MUSALLAH_BOARD_API.md | 12 +- docs/musallahboard-api.md | 301 ++++++++++++++++++ .../StringToBoardLocationConverter.java | 15 - .../controller/BoardAdminController.java | 65 ++-- .../controller/MusallahBoardController.java | 84 ++--- .../request/CreateCalendarEventRequest.java | 6 +- .../dto/request/CreatePosterRequest.java | 44 +-- .../request/UpdateCalendarEventRequest.java | 6 +- .../dto/request/UpdatePosterRequest.java | 42 +-- .../dto/response/MusallahBoardPayload.java | 4 +- .../dto/response/frames/EventView.java | 6 +- .../lensbridge/model/board/BoardLocation.java | 29 -- .../lensbridge/model/board/Event.java | 6 +- .../lensbridge/model/board/Poster.java | 6 +- .../DeviceConfig.java} | 20 +- .../repository/BoardConfigRepository.java | 10 - .../repository/sql/BoardConfigRepository.java | 7 +- .../repository/sql/BoardEventRepository.java | 13 +- .../repository/sql/PosterRepository.java | 14 +- .../lensbridge/service/BoardService.java | 59 ++-- .../lensbridge/service/PosterService.java | 42 ++- .../service/board/BoardContext.java | 18 +- .../service/board/BoardPayloadAssembler.java | 28 +- .../DailyScheduleFrameTransformer.java | 4 +- .../EventListFrameTransformer.java | 4 +- 25 files changed, 513 insertions(+), 332 deletions(-) create mode 100644 docs/musallahboard-api.md delete mode 100644 src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java rename src/main/java/com/ibrasoft/lensbridge/model/board/{BoardConfig.java => embedded/DeviceConfig.java} (55%) delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java diff --git a/docs/MUSALLAH_BOARD_API.md b/docs/MUSALLAH_BOARD_API.md index d1b0526..50cee92 100644 --- a/docs/MUSALLAH_BOARD_API.md +++ b/docs/MUSALLAH_BOARD_API.md @@ -117,7 +117,7 @@ Weekly rotating content (verse, hadith, Jummah info): Combined payload containing all data needed for the board: ```json { - "boardConfig": { ... }, + "deviceConfig": { ... }, "posterFrames": [ ... ], "upcomingEvents": [ ... ], "weeklyContent": { ... } @@ -271,7 +271,7 @@ Returns **all data** needed for the board in a single request. This is the **rec **Example Response**: ```json { - "boardConfig": { + "deviceConfig": { "boardLocation": "BROTHERS_MUSALLAH", "location": { "city": "Toronto", @@ -334,7 +334,7 @@ Returns **all data** needed for the board in a single request. This is the **rec ``` **Note**: Fields may be `null` if no data is configured: -- `boardConfig`: `null` if no config exists for the board +- `deviceConfig`: `null` if no config exists for the board - `weeklyContent`: `null` if no content is set for the current week - `posterFrames`: Empty array `[]` if no active posters - `upcomingEvents`: Empty array `[]` if no upcoming events @@ -351,7 +351,7 @@ const payload = await response.json(); ``` ### Periodic Refresh -Use the `refreshAfterIshaaMinutes` from `boardConfig` to determine when to refresh data after Isha prayer. Call `/payload` again to get updated content. +Use the `refreshAfterIshaaMinutes` from `deviceConfig` to determine when to refresh data after Isha prayer. Call `/payload` again to get updated content. ### Poster Display Loop 1. Use `posterFrames` array from the payload @@ -360,7 +360,7 @@ Use the `refreshAfterIshaaMinutes` from `boardConfig` to determine when to refre 4. If `posterFrames` is empty, show a default frame ### Dark Mode -Use `boardConfig.darkModeAfterIsha` and `boardConfig.darkModeMinutesAfterIsha` along with Isha prayer time to enable dark mode automatically. +Use `deviceConfig.darkModeAfterIsha` and `deviceConfig.darkModeMinutesAfterIsha` along with Isha prayer time to enable dark mode automatically. ### Prayer Times -Use `boardConfig.location` (latitude, longitude, method) with a prayer time calculation library (e.g., Adhan.js) to calculate prayer times locally. +Use `deviceConfig.location` (latitude, longitude, method) with a prayer time calculation library (e.g., Adhan.js) to calculate prayer times locally. diff --git a/docs/musallahboard-api.md b/docs/musallahboard-api.md new file mode 100644 index 0000000..e200ada --- /dev/null +++ b/docs/musallahboard-api.md @@ -0,0 +1,301 @@ +# MusallahBoard — Backend API Reference + +## Data Model + +### `IslamicQuote` +Shared type used for both Quranic verses and hadith. Each entry is self-describing via `kind`. + +```json +{ + "kind": "VERSE" | "HADITH", + "arabic": "string", + "transliteration": "string", + "translation": "string", + "reference": "string" +} +``` + +### `WeeklyContent` +Stored once per ISO week. Does not carry an audience — content is shared across boards. + +```json +{ + "weekId": { "year": 2026, "weekNumber": 19 }, + "quotes": [ IslamicQuote, ... ], + "jummahPrayer": [ + { "prayerTime": "13:30", "khatib": "string", "location": "string" } + ] +} +``` + +### `Event` +```json +{ + "id": "uuid", + "name": "string", + "description": "string", + "location": "string", + "startTimestamp": 1234567890000, + "endTimestamp": 1234567890000, + "allDay": false, + "audience": "BROTHERS" | "SISTERS" | "BOTH" +} +``` + +### `Poster` +```json +{ + "id": "uuid", + "title": "string", + "image": "https://...", + "duration": 15, + "startDate": "2026-05-01", + "endDate": "2026-05-15", + "audience": "BROTHERS" | "SISTERS" | "BOTH" +} +``` + +### `BoardConfig` +Full document stored per board location. Only returned on admin config endpoints. +`boardLocation` values: `BROTHERS_MUSALLAH`, `SISTERS_MUSALLAH`. + +```json +{ + "boardLocation": "BROTHERS_MUSALLAH", + "location": { + "city": "string", "country": "string", + "latitude": 0.0, "longitude": 0.0, + "timezone": "America/Toronto", "method": 2 + }, + "posterCycleInterval": 15000, + "refreshAfterIshaaMinutes": 30, + "darkModeAfterIsha": true, + "darkModeMinutesAfterIsha": 20, + "enableScrollingMessage": true, + "scrollingMessages": ["string"] +} +``` + +### `DisplayConfig` (payload only) +Subset of `BoardConfig` sent to the display board. Admin endpoints return the full `BoardConfig`. + +```json +{ + "city": "string", + "timezone": "America/Toronto", + "posterCycleInterval": 15000, + "darkModeAfterIsha": true, + "darkModeMinutesAfterIsha": 20, + "enableScrollingMessage": true, + "scrollingMessages": ["string"] +} +``` + +--- + +## Frame Types + +The `/payload` endpoint returns a `List`. Each frame has: + +```json +{ + "frameType": "poster" | "event_list" | "daily_schedule" | "next_prayer" | "jummah" | "islamic_quote", + "durationInSeconds": 15, + "slot": "PRIMARY" | "TICKER" | "SIDEBAR" | "OVERLAY", + "priority": null, + "frameConfig": { "type": "...", ...typeSpecificFields } +} +``` + +### `poster` +```json +{ "type": "poster", "posterUrl": "https://...", "title": "string" } +``` + +### `event_list` +```json +{ + "type": "event_list", + "heading": "This Week", + "events": [ + { + "name": "string", "description": "string", "location": "string", + "startTimestamp": 1234567890000, "endTimestamp": 1234567890000, "allDay": false + } + ] +} +``` + +### `daily_schedule` +Same shape as `event_list`, heading = `"Today"`. + +### `jummah` +```json +{ + "type": "jummah", + "prayers": [ + { "prayerTime": "13:30", "khatib": "string", "location": "string" } + ] +} +``` + +### `islamic_quote` +```json +{ + "type": "islamic_quote", + "kind": "VERSE" | "HADITH", + "arabic": "string", "transliteration": "string", + "translation": "string", "reference": "string" +} +``` + +### `next_prayer` *(placeholder)* +```json +{ "type": "next_prayer", "locationCity": "string", "timezone": "string", "calculationMethod": "string" } +``` + +--- + +## Admin API — `POST /api/admin/board/**` +All endpoints require `ROOT` role. All mutations are audit-logged. + +### Board Config + +| Method | Path | Body | Notes | +|--------|------|------|-------| +| `GET` | `/configs` | — | All configs | +| `GET` | `/configs/{boardLocation}` | — | Single config | +| `PUT` | `/configs/{boardLocation}` | `BoardConfig` | Full replace; `boardLocation` set from path | +| `PATCH` | `/configs/{boardLocation}` | `UpdateBoardConfigRequest` | Partial update | + +`boardLocation` path values: `BROTHERS_MUSALLAH`, `SISTERS_MUSALLAH` + +### Weekly Content + +| Method | Path | Body | Notes | +|--------|------|------|-------| +| `GET` | `/weekly-content` | — | All weeks | +| `GET` | `/weekly-content/year/{year}` | — | All weeks in a year | +| `GET` | `/weekly-content/{year}/{weekNumber}` | — | Single week | +| `PUT` | `/weekly-content/{year}/{weekNumber}` | `WeeklyContentRequest` | Upsert; null fields are left unchanged | +| `DELETE` | `/weekly-content/{year}/{weekNumber}` | — | — | + +#### `WeeklyContentRequest` +```json +{ + "quotes": [ + { "kind": "VERSE", "arabic": "...", "transliteration": "...", "translation": "...", "reference": "..." }, + { "kind": "HADITH", "arabic": "...", "transliteration": "...", "translation": "...", "reference": "..." } + ], + "jummahPrayer": [ + { "prayerTime": "13:30", "khatib": "Sheikh Name", "location": "Main Hall" } + ] +} +``` +All fields optional — send only what you want to update. + +### Posters + +| Method | Path | Body / Params | Notes | +|--------|------|------|-------| +| `GET` | `/posters` | `?board=` (optional) | All, or filtered by board audience | +| `GET` | `/posters/{posterId}` | — | Single poster | +| `POST` | `/posters` | `multipart/form-data` | Creates poster + uploads image | +| `PATCH` | `/posters/{posterId}` | `UpdatePosterRequest` | Metadata only | +| `PUT` | `/posters/{posterId}/image` | `multipart/form-data` | Image replacement | +| `DELETE` | `/posters/{posterId}` | — | — | + +**POST form fields:** `title`, `duration` (int seconds), `startDate` (ISO date), `endDate` (ISO date), `audience`, `image` (file) + +### Calendar Events + +| Method | Path | Body / Params | Notes | +|--------|------|------|-------| +| `GET` | `/events` | `?board=` (optional) | All, or filtered by board audience | +| `GET` | `/events/{eventId}` | — | Single event | +| `POST` | `/events` | `CreateCalendarEventRequest` | — | +| `PATCH` | `/events/{eventId}` | `UpdateCalendarEventRequest` | Null fields unchanged | +| `DELETE` | `/events/{eventId}` | — | — | + +#### `CreateCalendarEventRequest` +```json +{ + "name": "string", + "description": "string", + "location": "string", + "startTimestamp": 1234567890000, + "endTimestamp": 1234567890000, + "allDay": false, + "audience": "BROTHERS" | "SISTERS" | "BOTH" +} +``` + +### Signboard + +| Method | Path | Notes | +|--------|------|-------| +| `POST` | `/refresh` | Broadcasts `REFRESH` over WebSocket to all connected boards | + +--- + +## Display API — `GET /api/musallah/**` +No authentication required. Consumed by the board display hardware. + +| Method | Path | Params | Notes | +|--------|------|--------|-------| +| `GET` | `/payload` | `?board=` | **Primary endpoint.** Full frame list + display config | +| `GET` | `/config` | `?board=` | Raw board config | +| `GET` | `/weekly-content` | — | Current week's content | +| `GET` | `/weekly-content/{year}/{weekNumber}` | — | Specific week | +| `GET` | `/posters` | `?board=` | Active poster `FrameDefinition`s | +| `GET` | `/events` | `?board=` | Upcoming events | +| `GET` | `/events/range` | `?board=`, `?start=`, `?end=` | Events overlapping time range (epoch ms) | + +--- + +## Frontend Admin — Suggested Patterns + +### Weekly Content Form +The admin form should send a `PUT /weekly-content/{year}/{weekNumber}` with the full +`WeeklyContentRequest`. Because the endpoint is an upsert and null fields are left unchanged, +the form can load the existing week first (`GET /weekly-content/{year}/{weekNumber}`) and +pre-populate, then send the full payload back on save. + +```ts +interface WeeklyContentRequest { + quotes?: IslamicQuote[]; + jummahPrayer?: JummahPrayer[]; +} + +interface IslamicQuote { + kind: 'VERSE' | 'HADITH'; + arabic?: string; + transliteration?: string; + translation?: string; + reference?: string; +} +``` + +The admin form should render the `quotes` list dynamically (each item has a `kind` selector), +rather than hardcoding separate "verse" and "hadith" fields. This makes adding new quote types +(e.g. `DUAA`) a frontend-only change with no backend work. + +### Poster Filtering +The `?board=` query param on `GET /posters` replaces the old `/posters/by-board` sub-path. +To show all posters: `GET /posters`. To show only brothers' content: `GET /posters?board=BROTHERS_MUSALLAH`. + +### Event Filtering +Same pattern: `GET /events` (all), `GET /events?board=BROTHERS_MUSALLAH` (filtered). + +### Display Board Payload +Use `GET /payload?board=BROTHERS_MUSALLAH`. The response is: + +```ts +interface MusallahBoardPayload { + displayConfig: DisplayConfig; + frames: FrameDefinition[]; +} +``` + +Dispatch on `frame.frameType`, group by `frame.slot` for layout positioning. +See the frame type shapes above for each `frameConfig` structure. diff --git a/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java b/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java deleted file mode 100644 index 815d908..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToBoardLocationConverter.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ibrasoft.lensbridge.config.converter; - -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import org.springframework.core.convert.converter.Converter; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -@Component -public class StringToBoardLocationConverter implements Converter { - @Override - public BoardLocation convert(@NonNull String source) { - return BoardLocation.from(source) - .orElseThrow(() -> new IllegalArgumentException("Unknown board location: " + source)); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 2404835..b785afe 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -10,8 +10,7 @@ import com.ibrasoft.lensbridge.handler.SignboardHandler; import com.ibrasoft.lensbridge.model.audit.AdminAction; import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; @@ -35,8 +34,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDate; - +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -61,37 +59,36 @@ public class BoardAdminController { // ==================== Board Config Endpoints ==================== @GetMapping("/configs") - public ResponseEntity> getAllBoardConfigs() { + public ResponseEntity> getAllBoardConfigs() { log.debug("Admin fetching all board configs"); return ResponseEntity.ok(boardService.getAllBoardConfigs()); } - @GetMapping("/configs/{boardLocation}") - public ResponseEntity getBoardConfig(@PathVariable BoardLocation boardLocation) { - log.debug("Admin fetching board config for: {}", boardLocation); - return ResponseEntity.ok(boardService.getBoardConfigOrThrow(boardLocation)); + @GetMapping("/configs/{deviceId}") + public ResponseEntity getBoardConfig(@PathVariable UUID deviceId) { + log.debug("Admin fetching board config for device: {}", deviceId); + return ResponseEntity.ok(boardService.getBoardConfigOrThrow(deviceId)); } - @PutMapping("/configs/{boardLocation}") - public ResponseEntity saveBoardConfig( - @PathVariable BoardLocation boardLocation, - @Valid @RequestBody BoardConfig boardConfig, + @PutMapping("/configs/{deviceId}") + public ResponseEntity saveBoardConfig( + @PathVariable UUID deviceId, + @Valid @RequestBody DeviceConfig deviceConfig, HttpServletRequest request) { - log.info("Admin saving board config for: {}", boardLocation); - boardConfig.setBoardLocation(boardLocation); - BoardConfig saved = boardService.saveBoardConfig(boardConfig); + log.info("Admin saving board config for device: {}", deviceId); + DeviceConfig saved = boardService.saveBoardConfig(deviceId, deviceConfig); return ResponseEntity.ok(saved); } - @PatchMapping("/configs/{boardLocation}") - public ResponseEntity updateBoardConfig( - @PathVariable BoardLocation boardLocation, + @PatchMapping("/configs/{deviceId}") + public ResponseEntity updateBoardConfig( + @PathVariable UUID deviceId, @Valid @RequestBody UpdateBoardConfigRequest updateRequest, HttpServletRequest request) { - log.info("Admin updating board config for: {}", boardLocation); - BoardConfig updated = boardService.updateBoardConfig(boardLocation, updateRequest); + log.info("Admin updating board config for device: {}", deviceId); + DeviceConfig updated = boardService.updateBoardConfig(deviceId, updateRequest); return ResponseEntity.ok(updated); } @@ -148,11 +145,10 @@ public ResponseEntity> getAllPosters() { return ResponseEntity.ok(posterService.getAllPosters()); } - @GetMapping("/posters/by-board") - public ResponseEntity> getPostersForBoard( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Admin fetching posters for board: {}", boardLocation); - return ResponseEntity.ok(posterService.getPostersForBoard(boardLocation)); + @GetMapping("/posters/by-audience") + public ResponseEntity> getPostersForAudience(@RequestParam Audience audience) { + log.debug("Admin fetching posters for audience: {}", audience); + return ResponseEntity.ok(posterService.getPostersForAudience(audience)); } @GetMapping("/posters/{posterId}") @@ -165,8 +161,8 @@ public ResponseEntity getPosterById(@PathVariable UUID posterId) { public ResponseEntity createPoster( @RequestParam("title") String title, @RequestParam("duration") int duration, - @RequestParam("startDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - @RequestParam("endDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam("startTime") Instant startTime, + @RequestParam("endTime") Instant endTime, @RequestParam("audience") Audience audience, @RequestParam("image") MultipartFile imageFile, HttpServletRequest request) { @@ -176,8 +172,8 @@ public ResponseEntity createPoster( CreatePosterRequest createRequest = CreatePosterRequest.builder() .title(title) .duration(duration) - .startDate(startDate) - .endDate(endDate) + .startTime(startTime) + .endTime(endTime) .audience(audience) .build(); @@ -241,11 +237,10 @@ public ResponseEntity> getAllEvents() { return ResponseEntity.ok(boardService.getAllEvents()); } - @GetMapping("/events/by-board") - public ResponseEntity> getEventsForBoard( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Admin fetching calendar events for board: {}", boardLocation); - return ResponseEntity.ok(boardService.getEventsForBoard(boardLocation)); + @GetMapping("/events/by-audience") + public ResponseEntity> getEventsForAudience(@RequestParam Audience audience) { + log.debug("Admin fetching calendar events for audience: {}", audience); + return ResponseEntity.ok(boardService.getEventsForAudience(audience)); } @GetMapping("/events/{eventId}") diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index 92c8133..584ac68 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.controller; import com.ibrasoft.lensbridge.dto.response.MusallahBoardPayload; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; @@ -14,14 +14,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.util.List; +import java.util.UUID; -/** - * Controller for the Musallah Board display. - * Returns data formatted specifically for rendering on the board screens. - * These endpoints are publicly accessible (no authentication required) - * as they're consumed by the board display hardware. - */ @RestController @RequestMapping("/api/musallah") @RequiredArgsConstructor @@ -34,25 +30,16 @@ public class MusallahBoardController { // ==================== Board Configuration ==================== - /** - * Get the board configuration for a specific board location. - * Contains display settings like poster cycle interval, dark mode settings, etc. - */ @GetMapping("/config") - public ResponseEntity getBoardConfig( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching config for: {}", boardLocation); - return boardService.getBoardConfig(boardLocation) + public ResponseEntity getBoardConfig(@RequestParam UUID deviceId) { + log.debug("Musallah board fetching config for device: {}", deviceId); + return boardService.getBoardConfig(deviceId) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } // ==================== Weekly Content ==================== - /** - * Get the current week's content (verse, hadith, jummah prayer info). - * Returns 404 if no content is set for the current week. - */ @GetMapping("/weekly-content") public ResponseEntity getCurrentWeeklyContent() { log.debug("Musallah board fetching current weekly content"); @@ -61,9 +48,6 @@ public ResponseEntity getCurrentWeeklyContent() { .orElse(ResponseEntity.notFound().build()); } - /** - * Get weekly content for a specific week. - */ @GetMapping("/weekly-content/{year}/{weekNumber}") public ResponseEntity getWeeklyContent( @PathVariable int year, @@ -76,63 +60,37 @@ public ResponseEntity getWeeklyContent( // ==================== Poster Frames ==================== - /** - * Get active poster frames for display on the board. - * Returns FrameDefinition objects containing poster URLs and display duration. - * Only returns posters that are currently active (within their date window) - * and match the board's audience. - * Sorted by startDate descending (newest first). - */ @GetMapping("/posters") - public ResponseEntity> getActivePosterFrames( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching active poster frames for: {}", boardLocation); - List frames = posterService.getActivePosterFrameDefinitions(boardLocation); + public ResponseEntity> getActivePosterFrames(@RequestParam Audience audience) { + log.debug("Musallah board fetching active poster frames for audience: {}", audience); + List frames = posterService.getActivePosterFrameDefinitions(audience); return ResponseEntity.ok(frames); } // ==================== Calendar Events ==================== - /** - * Get upcoming calendar events for display on the board. - * Returns events that match the board's audience (or BOTH), - * filtered to only upcoming events (startTimestamp >= now). - * Sorted by startTimestamp ascending. - */ @GetMapping("/events") - public ResponseEntity> getUpcomingEvents( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching upcoming events for: {}", boardLocation); - List events = boardService.getUpcomingEventsForBoard(boardLocation); + public ResponseEntity> getUpcomingEvents(@RequestParam Audience audience) { + log.debug("Musallah board fetching upcoming events for audience: {}", audience); + List events = boardService.getUpcomingEventsForAudience(audience); return ResponseEntity.ok(events); } - /** - * Get calendar events within a specific time range for display on the board. - * Useful for "week at a glance" or daily schedule views. - * Returns events that overlap with the given time range. - */ @GetMapping("/events/range") public ResponseEntity> getEventsInRange( - @RequestParam("board") BoardLocation boardLocation, - @RequestParam("start") long rangeStart, - @RequestParam("end") long rangeEnd) { - log.debug("Musallah board fetching events for {} in range {} - {}", boardLocation, rangeStart, rangeEnd); - List events = boardService.getEventsForBoardInRange(boardLocation, rangeStart, rangeEnd); + @RequestParam Audience audience, + @RequestParam("start") Instant rangeStart, + @RequestParam("end") Instant rangeEnd) { + log.debug("Musallah board fetching events for {} in range {} - {}", audience, rangeStart, rangeEnd); + List events = boardService.getEventsForAudienceInRange(audience, rangeStart, rangeEnd); return ResponseEntity.ok(events); } // ==================== Combined Payload ==================== - /** - * Get all data needed for the board in a single request. - * Returns board config, active posters (as FrameDefinitions), upcoming events, and current week's content. - * This reduces the number of API calls the board needs to make on refresh. - */ @GetMapping("/payload") - public ResponseEntity getBoardPayload( - @RequestParam("board") BoardLocation boardLocation) { - log.debug("Musallah board fetching full payload for: {}", boardLocation); - return ResponseEntity.ok(payloadAssembler.assemble(boardLocation)); + public ResponseEntity getBoardPayload(@RequestParam UUID deviceId) { + log.debug("Musallah board fetching full payload for device: {}", deviceId); + return ResponseEntity.ok(payloadAssembler.assemble(deviceId)); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java index e6eb97b..66bc91e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java @@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotNull; import lombok.*; +import java.time.Instant; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateCalendarEventRequest { @NotBlank(message = "Event name is required") @@ -12,9 +14,9 @@ public class CreateCalendarEventRequest { private String description; private String location; @NotNull(message = "Start time is required") - private Long startEpochMs; + private Instant startTime; @NotNull(message = "End time is required") - private Long endEpochMs; + private Instant endTime; private Boolean allDay; @NotNull(message = "Audience is required") private Audience audience; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java index 2e890e7..dd7300f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java @@ -1,47 +1,27 @@ package com.ibrasoft.lensbridge.dto.request; -import java.time.LocalDate; - import com.ibrasoft.lensbridge.model.board.Audience; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +import java.time.Instant; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreatePosterRequest { - @NotBlank(message = "Title is required") private String title; - - /** - * Duration in seconds for how long the poster should be displayed. - */ + @Positive(message = "Duration must be positive") private int duration; - - /** - * The date from which the poster becomes active (inclusive). - */ - @NotNull(message = "Start date is required") - private LocalDate startDate; - - /** - * The date until which the poster remains active (exclusive). - */ - @NotNull(message = "End date is required") - private LocalDate endDate; - - /** - * Target audience for the poster. - */ + + @NotNull(message = "Start time is required") + private Instant startTime; + + @NotNull(message = "End time is required") + private Instant endTime; + @NotNull(message = "Audience is required") private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java index 9e09a3e..a0463ad 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java @@ -3,13 +3,15 @@ import com.ibrasoft.lensbridge.model.board.Audience; import lombok.*; +import java.time.Instant; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateCalendarEventRequest { private String name; private String description; private String location; - private Long startEpochMs; - private Long endEpochMs; + private Instant startTime; + private Instant endTime; private Boolean allDay; private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java index bb95024..0e3a3d5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java @@ -1,45 +1,19 @@ package com.ibrasoft.lensbridge.dto.request; -import java.time.LocalDate; - import com.ibrasoft.lensbridge.model.board.Audience; - import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; + +import java.time.Instant; -/** - * Request DTO for updating poster information. - * All fields are optional - only non-null fields will be updated. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdatePosterRequest { - private String title; - - /** - * Duration in seconds for how long the poster should be displayed. - */ + @Positive(message = "Duration must be positive") private Integer duration; - - /** - * The date from which the poster becomes active (inclusive). - */ - private LocalDate startDate; - - /** - * The date until which the poster remains active (exclusive). - */ - private LocalDate endDate; - - /** - * Target audience for the poster. - */ + + private Instant startTime; + private Instant endTime; private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java index b618c7f..9ad56a2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.dto.response; -import com.ibrasoft.lensbridge.model.board.BoardConfig; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,6 +14,6 @@ @NoArgsConstructor @AllArgsConstructor public class MusallahBoardPayload { - private BoardConfig boardConfig; + private DeviceConfig deviceConfig; private List frames; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java index be6aa58..69ca1d3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java @@ -2,12 +2,14 @@ import lombok.*; +import java.time.Instant; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class EventView { private String name; private String description; private String location; - private Long startEpochMs; - private Long endEpochMs; + private Instant startTime; + private Instant endTime; private Boolean allDay; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java deleted file mode 100644 index 09d0199..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardLocation.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ibrasoft.lensbridge.model.board; - -import java.util.Arrays; -import java.util.Optional; - -public enum BoardLocation { - BROTHERS_MUSALLAH("brothers", Audience.BROTHERS), - SISTERS_MUSALLAH("sisters", Audience.SISTERS); - - private final String locationName; - private final Audience audience; - - BoardLocation(String locationName, Audience audience) { - this.locationName = locationName; - this.audience = audience; - } - - public Audience audience() { return audience; } - - public static Optional from(String s) { - if (s == null) return Optional.empty(); - return Arrays.stream(values()) - .filter(b -> b.name().equalsIgnoreCase(s) || b.toString().equalsIgnoreCase(s)) - .findFirst(); - } - - @Override - public String toString() { return locationName; } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java index c35d997..85a0ba5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java @@ -2,6 +2,8 @@ import jakarta.persistence.*; import lombok.*; + +import java.time.Instant; import java.util.UUID; @Entity @@ -14,8 +16,8 @@ public class Event { private String name; private String description; private String location; - private Long startEpochMs; - private Long endEpochMs; + private Instant startTime; + private Instant endTime; private Boolean allDay; @Enumerated(EnumType.STRING) private Audience audience; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java index f655b94..a217cc9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import java.time.LocalDate; +import java.time.Instant; import java.util.UUID; @Entity @@ -15,8 +15,8 @@ public class Poster { private String title; private String image; private int duration; - private LocalDate startDate; - private LocalDate endDate; + private Instant startTime; + private Instant endTime; @Enumerated(EnumType.STRING) private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java similarity index 55% rename from src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java rename to src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java index d898209..43bc596 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -1,17 +1,23 @@ -package com.ibrasoft.lensbridge.model.board; +package com.ibrasoft.lensbridge.model.board.embedded; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.Location; import jakarta.persistence.*; import lombok.*; import java.util.List; +import java.util.UUID; @Entity @Table(name = "board_configs") @Data @Builder @NoArgsConstructor @AllArgsConstructor -public class BoardConfig { +public class DeviceConfig { @Id - @Enumerated(EnumType.STRING) - @Column(name = "board_location", length = 50) - private BoardLocation boardLocation; + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "device_id") + private Device device; @Embedded private Location location; @@ -19,12 +25,12 @@ public class BoardConfig { private int posterCycleIntervalMs; private int refreshAfterIshaMinutes; private boolean darkModeAfterIsha; - private int darkModeAfterIshaMinutes; + private int darkModeAfterMaghribMinutes; private boolean enableScrollingMessage; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "board_config_messages", - joinColumns = @JoinColumn(name = "board_location")) + joinColumns = @JoinColumn(name = "device_id")) @Column(name = "message") private List scrollingMessages; } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java deleted file mode 100644 index 879e05b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/BoardConfigRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface BoardConfigRepository extends MongoRepository { -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java index a01a932..c006365 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardConfigRepository.java @@ -1,9 +1,10 @@ package com.ibrasoft.lensbridge.repository.sql; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.UUID; + @Repository -public interface BoardConfigRepository extends JpaRepository {} +public interface BoardConfigRepository extends JpaRepository {} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java index 690a4a6..3ed22ff 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java @@ -7,20 +7,21 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.Instant; import java.util.List; import java.util.UUID; @Repository public interface BoardEventRepository extends JpaRepository { - List findAllByOrderByStartEpochMsAsc(); + List findAllByOrderByStartTimeAsc(); - @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY e.startEpochMs ASC") + @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY e.startTime ASC") List findByAudienceOrBoth(@Param("aud") Audience audience); - @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startEpochMs >= :now ORDER BY e.startEpochMs ASC") - List findUpcomingByAudienceOrBoth(@Param("aud") Audience audience, @Param("now") long now); + @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime >= :now ORDER BY e.startTime ASC") + List findUpcomingByAudienceOrBoth(@Param("aud") Audience audience, @Param("now") Instant now); - @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startEpochMs <= :rangeEnd AND e.endEpochMs >= :rangeStart ORDER BY e.startEpochMs ASC") - List findOverlappingForAudienceOrBoth(@Param("aud") Audience audience, @Param("rangeStart") long rangeStart, @Param("rangeEnd") long rangeEnd); + @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime <= :rangeEnd AND e.endTime >= :rangeStart ORDER BY e.startTime ASC") + List findOverlappingForAudienceOrBoth(@Param("aud") Audience audience, @Param("rangeStart") Instant rangeStart, @Param("rangeEnd") Instant rangeEnd); } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java index 696f9b6..30ec438 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java @@ -7,21 +7,21 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDate; +import java.time.Instant; import java.util.List; import java.util.UUID; @Repository public interface PosterRepository extends JpaRepository { - List findAllByOrderByStartDateDesc(); + List findAllByOrderByStartTimeDesc(); - @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY p.startDate DESC") + @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY p.startTime DESC") List findByAudienceOrBoth(@Param("aud") Audience audience); - @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND p.startDate <= :today AND p.endDate > :today ORDER BY p.startDate DESC") - List findActivePostersForAudienceAt(@Param("today") LocalDate today, @Param("aud") Audience audience); + @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND p.startTime <= :now AND p.endTime > :now ORDER BY p.startTime DESC") + List findActivePostersForAudienceAt(@Param("now") Instant now, @Param("aud") Audience audience); - @Query("SELECT p FROM Poster p WHERE p.startDate <= :today AND p.endDate > :today") - List findActivePostersAt(@Param("today") LocalDate today); + @Query("SELECT p FROM Poster p WHERE p.startTime <= :now AND p.endTime > :now") + List findActivePostersAt(@Param("now") Instant now); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index b237cdd..1c83bbe 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -7,8 +7,10 @@ import com.ibrasoft.lensbridge.dto.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.*; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; import com.ibrasoft.lensbridge.repository.sql.BoardEventRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.repository.sql.WeeklyContentRepository; import com.ibrasoft.lensbridge.util.Patch; import lombok.RequiredArgsConstructor; @@ -30,43 +32,49 @@ public class BoardService { private final BoardConfigRepository boardConfigRepository; + private final DeviceRepository deviceRepository; private final BoardEventRepository boardEventRepository; private final WeeklyContentRepository weeklyContentRepository; // ==================== Board Config ==================== - public Optional getBoardConfig(BoardLocation boardLocation) { - return boardConfigRepository.findById(boardLocation); + public Optional getBoardConfig(UUID deviceId) { + return boardConfigRepository.findById(deviceId); } - public BoardConfig getBoardConfigOrThrow(BoardLocation boardLocation) { - return boardConfigRepository.findById(boardLocation) + public DeviceConfig getBoardConfigOrThrow(UUID deviceId) { + return boardConfigRepository.findById(deviceId) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, - ErrorResponse.of("Board configuration not found for location: " + boardLocation))); + ErrorResponse.of("Board configuration not found for device: " + deviceId))); } - public BoardConfig saveBoardConfig(BoardConfig boardConfig) { - BoardConfig saved = boardConfigRepository.save(boardConfig); - log.info("Saved board config for location: {}", boardConfig.getBoardLocation()); + public DeviceConfig saveBoardConfig(UUID deviceId, DeviceConfig deviceConfig) { + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Device not found: " + deviceId))); + deviceConfig.setDevice(device); + DeviceConfig saved = boardConfigRepository.save(deviceConfig); + log.info("Saved board config for device: {}", deviceId); return saved; } - public BoardConfig updateBoardConfig(BoardLocation boardLocation, UpdateBoardConfigRequest request) { - BoardConfig existing = getBoardConfigOrThrow(boardLocation); + public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest request) { + DeviceConfig existing = getBoardConfigOrThrow(deviceId); Patch.apply(request.getLocation(), existing::setLocation); Patch.apply(request.getPosterCycleIntervalMs(), existing::setPosterCycleIntervalMs); Patch.apply(request.getRefreshAfterIshaMinutes(), existing::setRefreshAfterIshaMinutes); Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); - Patch.apply(request.getDarkModeAfterIshaMinutes(), existing::setDarkModeAfterIshaMinutes); + Patch.apply(request.getDarkModeAfterIshaMinutes(), existing::setDarkModeAfterMaghribMinutes); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); - BoardConfig saved = boardConfigRepository.save(existing); - log.info("Updated board config for location: {}", boardLocation); + DeviceConfig saved = boardConfigRepository.save(existing); + log.info("Updated board config for device: {}", deviceId); return saved; } - public List getAllBoardConfigs() { + public List getAllBoardConfigs() { return boardConfigRepository.findAll(); } @@ -148,7 +156,7 @@ public void deleteWeeklyContent(int year, int weekNumber) { // ==================== Events ==================== public List getAllEvents() { - return boardEventRepository.findAllByOrderByStartEpochMsAsc(); + return boardEventRepository.findAllByOrderByStartTimeAsc(); } public Event getEventById(UUID eventId) { @@ -158,17 +166,16 @@ public Event getEventById(UUID eventId) { ErrorResponse.of("Event not found with id: " + eventId))); } - public List getEventsForBoard(BoardLocation boardLocation) { - return boardEventRepository.findByAudienceOrBoth(boardLocation.audience()); + public List getEventsForAudience(Audience audience) { + return boardEventRepository.findByAudienceOrBoth(audience); } - public List getUpcomingEventsForBoard(BoardLocation boardLocation) { - long now = Instant.now().toEpochMilli(); - return boardEventRepository.findUpcomingByAudienceOrBoth(boardLocation.audience(), now); + public List getUpcomingEventsForAudience(Audience audience) { + return boardEventRepository.findUpcomingByAudienceOrBoth(audience, Instant.now()); } - public List getEventsForBoardInRange(BoardLocation boardLocation, long rangeStart, long rangeEnd) { - return boardEventRepository.findOverlappingForAudienceOrBoth(boardLocation.audience(), rangeStart, rangeEnd); + public List getEventsForAudienceInRange(Audience audience, Instant rangeStart, Instant rangeEnd) { + return boardEventRepository.findOverlappingForAudienceOrBoth(audience, rangeStart, rangeEnd); } public Event createEvent(CreateCalendarEventRequest request) { @@ -176,8 +183,8 @@ public Event createEvent(CreateCalendarEventRequest request) { .name(request.getName()) .description(request.getDescription()) .location(request.getLocation()) - .startEpochMs(request.getStartEpochMs()) - .endEpochMs(request.getEndEpochMs()) + .startTime(request.getStartTime()) + .endTime(request.getEndTime()) .allDay(request.getAllDay()) .audience(request.getAudience()) .build(); @@ -191,8 +198,8 @@ public Event updateEvent(UUID eventId, UpdateCalendarEventRequest request) { Patch.apply(request.getName(), existing::setName); Patch.apply(request.getDescription(), existing::setDescription); Patch.apply(request.getLocation(), existing::setLocation); - Patch.apply(request.getStartEpochMs(), existing::setStartEpochMs); - Patch.apply(request.getEndEpochMs(), existing::setEndEpochMs); + Patch.apply(request.getStartTime(), existing::setStartTime); + Patch.apply(request.getEndTime(), existing::setEndTime); Patch.apply(request.getAllDay(), existing::setAllDay); Patch.apply(request.getAudience(), existing::setAudience); Event saved = boardEventRepository.save(existing); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index 73c8ad6..d8a3c8e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -4,8 +4,9 @@ import com.ibrasoft.lensbridge.dto.request.UpdatePosterRequest; import com.ibrasoft.lensbridge.dto.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; +import java.time.Instant; import com.ibrasoft.lensbridge.service.board.BoardContext; import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; import com.ibrasoft.lensbridge.util.Patch; @@ -18,7 +19,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.time.LocalDate; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -39,7 +39,7 @@ public class PosterService { * Get all posters, sorted by startDate descending (newest first). */ public List getAllPosters() { - return posterRepository.findAllByOrderByStartDateDesc(); + return posterRepository.findAllByOrderByStartTimeDesc(); } /** @@ -57,33 +57,31 @@ public Poster getPosterById(UUID posterId) { * Get all posters for a specific board location. * Returns posters that match the board's audience or BOTH. */ - public List getPostersForBoard(BoardLocation boardLocation) { - return posterRepository.findByAudienceOrBoth(boardLocation.audience()); + public List getPostersForAudience(Audience audience) { + return posterRepository.findByAudienceOrBoth(audience); } /** - * Get active posters for a specific board location as FrameDefinitions. + * Get active posters for a specific audience as FrameDefinitions. * Returns posters that are currently active (startDate <= today < endDate) - * and match the board's audience or BOTH. + * and match the audience or BOTH. */ - public List getActivePosterFramesForBoard(BoardLocation boardLocation) { - LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience()); + public List getActivePosterFramesForAudience(Audience audience) { + return posterRepository.findActivePostersForAudienceAt(Instant.now(), audience); } /** * Get all active posters (currently within their viewing window). */ public List getActivePosters() { - LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersAt(today); + return posterRepository.findActivePostersAt(Instant.now()); } /** * Create a new poster with an uploaded image. */ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) { - validateDates(request.getStartDate(), request.getEndDate()); + validateDates(request.getStartTime(), request.getEndTime()); validateImageFile(imageFile); // Upload the image to R2 @@ -103,8 +101,8 @@ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) .title(request.getTitle()) .image(publicUrl + "/" + objectKey) .duration(request.getDuration()) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) + .startTime(request.getStartTime()) + .endTime(request.getEndTime()) .audience(request.getAudience()) .build(); @@ -126,12 +124,11 @@ public Poster updatePoster(UUID posterId, UpdatePosterRequest request) { // Update only non-null fields Patch.apply(request.getTitle(), poster::setTitle); Patch.apply(request.getDuration(), poster::setDuration); - Patch.apply(request.getStartDate(), poster::setStartDate); - Patch.apply(request.getEndDate(), poster::setEndDate); + Patch.apply(request.getStartTime(), poster::setStartTime); + Patch.apply(request.getEndTime(), poster::setEndTime); Patch.apply(request.getAudience(), poster::setAudience); - // Validate dates after update - validateDates(poster.getStartDate(), poster.getEndDate()); + validateDates(poster.getStartTime(), poster.getEndTime()); poster = posterRepository.save(poster); log.info("Updated poster: id={}", posterId); @@ -210,9 +207,8 @@ public void deletePoster(UUID posterId) { * Returns only posters that are currently active and match the board's audience. * Sorted by startDate descending (newest first). */ - public List getActivePosterFrameDefinitions(BoardLocation boardLocation) { - LocalDate today = LocalDate.now(); - return posterRepository.findActivePostersForAudienceAt(today, boardLocation.audience()) + public List getActivePosterFrameDefinitions(Audience audience) { + return posterRepository.findActivePostersForAudienceAt(Instant.now(), audience) .stream() .map(this::toFrameDefinition) .collect(Collectors.toList()); @@ -236,7 +232,7 @@ private String generatePosterFilename(String originalFilename) { return "poster-" + UUID.randomUUID() + extension; } - private void validateDates(LocalDate startDate, LocalDate endDate) { + private void validateDates(Instant startDate, Instant endDate) { if (startDate != null && endDate != null && !endDate.isAfter(startDate)) { throw new ApiResponseException( HttpStatus.BAD_REQUEST, diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java index 3cbae53..aae0889 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service.board; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.model.board.Device; import lombok.Builder; import lombok.Value; @@ -15,16 +15,15 @@ @Value @Builder public class BoardContext { - BoardLocation location; - BoardConfig config; + Device device; + DeviceConfig config; ZonedDateTime now; public ZoneId zone() { - // Prefer the board's configured timezone; fall back to system default. if (config != null && config.getLocation() != null && config.getLocation().getTimezone() != null) { try { return ZoneId.of(config.getLocation().getTimezone()); - } catch (Exception ignored) { /* fallthrough */ } + } catch (Exception ignored) {} } return ZoneId.systemDefault(); } @@ -49,14 +48,15 @@ public Instant currentDayEnd() { return now.with(LocalTime.MAX).toInstant(); } - public static BoardContext of(BoardLocation location, BoardConfig config) { + public static BoardContext of(Device device) { + DeviceConfig config = device.getConfig(); ZoneId zone = ZoneId.systemDefault(); if (config != null && config.getLocation() != null && config.getLocation().getTimezone() != null) { try { zone = ZoneId.of(config.getLocation().getTimezone()); } - catch (Exception ignored) { /* keep default */ } + catch (Exception ignored) {} } return BoardContext.builder() - .location(location) + .device(device) .config(config) .now(ZonedDateTime.now(zone)) .build(); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index 773d0c3..8d550d3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -1,12 +1,14 @@ package com.ibrasoft.lensbridge.service.board; +import com.ibrasoft.lensbridge.dto.response.ErrorResponse; import com.ibrasoft.lensbridge.dto.response.MusallahBoardPayload; -import com.ibrasoft.lensbridge.model.board.BoardConfig; -import com.ibrasoft.lensbridge.model.board.BoardLocation; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.board.transformer.EventListFrameTransformer; @@ -14,10 +16,12 @@ import com.ibrasoft.lensbridge.service.board.transformer.WeeklyContentFrameTransformer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -26,13 +30,18 @@ public class BoardPayloadAssembler { private final BoardService boardService; private final PosterService posterService; + private final DeviceRepository deviceRepository; private final PosterFrameTransformer posterTransformer; private final EventListFrameTransformer eventListTransformer; private final WeeklyContentFrameTransformer weeklyContentTransformer; - public MusallahBoardPayload assemble(BoardLocation location) { - BoardConfig config = boardService.getBoardConfig(location).orElse(null); - BoardContext ctx = BoardContext.of(location, config); + public MusallahBoardPayload assemble(UUID deviceId) { + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Device not found: " + deviceId))); + + BoardContext ctx = BoardContext.of(device); List frames = new ArrayList<>(); frames.addAll(posterFrames(ctx)); @@ -40,22 +49,21 @@ public MusallahBoardPayload assemble(BoardLocation location) { frames.addAll(weeklyContentFrames(ctx)); return MusallahBoardPayload.builder() - .boardConfig(config) + .deviceConfig(ctx.getConfig()) .frames(frames) .build(); } private List posterFrames(BoardContext ctx) { - List posters = posterService.getActivePosterFramesForBoard(ctx.getLocation()); + List posters = posterService.getActivePosterFramesForAudience(ctx.getDevice().getAudience()); List out = new ArrayList<>(posters.size()); for (Poster p : posters) out.add(posterTransformer.transform(p, ctx)); return out; } private List eventFrames(BoardContext ctx) { - long weekStart = ctx.currentWeekStart().toEpochMilli(); - long weekEnd = ctx.currentWeekEnd().toEpochMilli(); - List events = boardService.getEventsForBoardInRange(ctx.getLocation(), weekStart, weekEnd); + List events = boardService.getEventsForAudienceInRange( + ctx.getDevice().getAudience(), ctx.currentWeekStart(), ctx.currentWeekEnd()); if (events.isEmpty()) return List.of(); return List.of(eventListTransformer.transform(events, ctx)); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java index 64f184c..d710c61 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java @@ -49,8 +49,8 @@ private EventView toEventView(Event event) { .name(event.getName()) .description(event.getDescription()) .location(event.getLocation()) - .startEpochMs(event.getStartEpochMs()) - .endEpochMs(event.getEndEpochMs()) + .startTime(event.getStartTime()) + .endTime(event.getEndTime()) .allDay(event.getAllDay()) .build(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java index 3e01f44..03aa90d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java @@ -49,8 +49,8 @@ private EventView toEventView(Event event) { .name(event.getName()) .description(event.getDescription()) .location(event.getLocation()) - .startEpochMs(event.getStartEpochMs()) - .endEpochMs(event.getEndEpochMs()) + .startTime(event.getStartTime()) + .endTime(event.getEndTime()) .allDay(event.getAllDay()) .build(); } From b8fad1b806fb9516338be9e9e68771c50b0332e6 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sat, 9 May 2026 00:26:26 -0400 Subject: [PATCH 04/52] agent WebSocket handler + session + challenge-response auth --- .../lensbridge/config/WebSocketConfig.java | 14 +- .../lensbridge/dto/agent/AuthFrame.java | 26 ++ .../lensbridge/dto/agent/HeartbeatFrame.java | 31 ++ .../dto/agent/IncomingAgentFrame.java | 41 +++ .../dto/agent/OutgoingAgentFrame.java | 34 ++ .../handler/AgentWebSocketHandler.java | 222 +++++++++++++ .../security/WebSecurityConfig.java | 1 + .../service/agent/AgentSession.java | 106 ++++++ .../service/agent/AgentSessionRegistry.java | 45 +++ .../service/agent/HeartbeatService.java | 59 ++++ .../agent/handshake/AuthSignaturePayload.java | 35 ++ .../handler/AgentWebSocketHandlerTest.java | 305 ++++++++++++++++++ .../service/agent/Ed25519TestUtil.java | 49 +++ 13 files changed, 965 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519TestUtil.java diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java index 49871d7..3266709 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java @@ -7,27 +7,35 @@ import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import com.ibrasoft.lensbridge.handler.AgentWebSocketHandler; import com.ibrasoft.lensbridge.handler.SignboardHandler; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { - + private final SignboardHandler signboardHandler; - + private final AgentWebSocketHandler agentWebSocketHandler; + @Value("${frontend.baseurl}") String frontendBaseUrl; @Value("${musallahboard.baseurl}") String musallahBoardBaseUrl; - public WebSocketConfig(SignboardHandler signboardHandler) { + public WebSocketConfig(SignboardHandler signboardHandler, + AgentWebSocketHandler agentWebSocketHandler) { this.signboardHandler = signboardHandler; + this.agentWebSocketHandler = agentWebSocketHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(signboardHandler, "/api/refresh-musallahboard") .setAllowedOrigins(frontendBaseUrl, musallahBoardBaseUrl); + + // Agents authenticate per-frame inside the channel; the WS upgrade itself is open. + registry.addHandler(agentWebSocketHandler, "/api/agent/ws") + .setAllowedOrigins("*"); } } \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java new file mode 100644 index 0000000..534f1b8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** + * Agent → backend authentication frame, sent in response to a {@code hello}. + *

+ * The signature covers a fixed canonical string. See {@link com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload}. + */ +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +public class AuthFrame extends IncomingAgentFrame { + + private UUID deviceId; + + /** Unix milliseconds; server enforces |timestamp - now| within a skew window. */ + private long timestamp; + + /** Base64-encoded 64-byte Ed25519 signature. */ + private String signature; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java new file mode 100644 index 0000000..275e3fa --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +public class HeartbeatFrame extends IncomingAgentFrame { + + private Telemetry telemetry; + + @Getter + @Setter + public static class Telemetry { + private Long uptimeSec; + private Double cpuTempC; + private String throttleFlags; + private Integer memUsedMb; + private Integer memTotalMb; + private Integer diskUsedPct; + private Boolean kioskAlive; + private List ipv4; + private String wifiSsid; + private UUID displayedFrameId; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java new file mode 100644 index 0000000..5a0a1ba --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.util.UUID; + +/** + * Polymorphic envelope for frames sent from the agent to the backend. + * Discriminated by the {@code type} field. + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AuthFrame.class, name = "auth"), + @JsonSubTypes.Type(value = HeartbeatFrame.class, name = "heartbeat") +}) +public abstract class IncomingAgentFrame { + + /** Discriminator (kept for logging / rejection messages even though Jackson uses it as the type id). */ + private String type; + + /** Monotonic sequence number, starts at 1 from the agent. */ + private long seq; + + /** Session identifier issued by the backend in the {@code hello} frame. */ + private UUID sessionId; + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public long getSeq() { return seq; } + public void setSeq(long seq) { this.seq = seq; } + + public UUID getSessionId() { return sessionId; } + public void setSessionId(UUID sessionId) { this.sessionId = sessionId; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java new file mode 100644 index 0000000..e5736cb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +/** + * Backend → agent frame envelope. Built up via the builder for each emission; + * Jackson serializes to {@code type, seq, sessionId, ...payload}. + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OutgoingAgentFrame { + + private String type; + private long seq; + private UUID sessionId; + + // ── hello ──────────────────────────────────────────────────────────── + /** Base64-encoded 32 random bytes. Single-use, scoped to this session. */ + private String challenge; + private Long serverTime; + + // ── auth_ok ────────────────────────────────────────────────────────── + private UUID deviceId; + private Integer heartbeatIntervalMs; + + // ── error ──────────────────────────────────────────────────────────── + private String error; + private String reason; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java new file mode 100644 index 0000000..697b0a9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java @@ -0,0 +1,222 @@ +package com.ibrasoft.lensbridge.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.agent.AuthFrame; +import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.dto.agent.IncomingAgentFrame; +import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.AgentSession; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.HeartbeatService; +import com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload; +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +/** + * Lifecycle of one agent WebSocket: + *

+ *   open      → server generates challenge, sends hello (seq:1)
+ *   recv auth → verify Ed25519 sig + timestamp + revocation; on success bind deviceId,
+ *               send auth_ok (seq:2), register in {@link AgentSessionRegistry}
+ *   recv hb   → persist via {@link HeartbeatService}
+ *   recv ?    → close 4001 (unauth) / 4003 (bad-state) / 4002 (seq replay)
+ *   close     → unregister
+ * 
+ */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AgentWebSocketHandler extends TextWebSocketHandler { + + static final String SESSION_ATTR = "agent.session"; + static final long TIMESTAMP_SKEW_MS = 5 * 60 * 1000L; + static final int CHALLENGE_BYTES = 32; + static final int HEARTBEAT_INTERVAL_MS = 30_000; + + static final CloseStatus CLOSE_AUTH_FAILED = new CloseStatus(4001, "auth_failed"); + static final CloseStatus CLOSE_SEQ_VIOLATION = new CloseStatus(4002, "seq_violation"); + static final CloseStatus CLOSE_BAD_STATE = new CloseStatus(4003, "bad_state"); + static final CloseStatus CLOSE_BAD_FRAME = new CloseStatus(4004, "bad_frame"); + + private final ObjectMapper objectMapper; + private final Ed25519Verifier ed25519; + private final DeviceRepository deviceRepository; + private final AgentSessionRegistry registry; + private final HeartbeatService heartbeatService; + private final SecureRandom random = new SecureRandom(); + + @Override + public void afterConnectionEstablished(WebSocketSession transport) throws Exception { + String challenge = newChallenge(); + AgentSession session = new AgentSession(transport, challenge, objectMapper); + transport.getAttributes().put(SESSION_ATTR, session); + + OutgoingAgentFrame hello = session.populateOutgoing(OutgoingAgentFrame.builder() + .type("hello") + .challenge(challenge) + .serverTime(System.currentTimeMillis())); + session.send(hello); + } + + @Override + protected void handleTextMessage(WebSocketSession transport, TextMessage message) { + AgentSession session = (AgentSession) transport.getAttributes().get(SESSION_ATTR); + if (session == null) { + safeClose(transport, CLOSE_BAD_STATE); + return; + } + + IncomingAgentFrame frame; + try { + frame = objectMapper.readValue(message.getPayload(), IncomingAgentFrame.class); + } catch (Exception e) { + log.warn("session={} unparseable frame: {}", session.getSessionId(), e.getMessage()); + session.close(CLOSE_BAD_FRAME); + return; + } + + if (frame.getSessionId() == null || !frame.getSessionId().equals(session.getSessionId())) { + log.warn("session={} frame sessionId mismatch (got {})", session.getSessionId(), frame.getSessionId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + if (!session.acceptIncomingSeq(frame.getSeq())) { + session.close(CLOSE_SEQ_VIOLATION); + return; + } + + switch (session.getPhase()) { + case UNAUTH -> { + if (frame instanceof AuthFrame auth) { + handleAuth(session, auth); + } else { + log.warn("session={} expected auth frame, got {}", session.getSessionId(), frame.getType()); + session.close(CLOSE_BAD_STATE); + } + } + case AUTHED -> { + if (frame instanceof HeartbeatFrame hb) { + handleHeartbeat(session, hb); + } else if (frame instanceof AuthFrame) { + log.warn("session={} duplicate auth after auth_ok", session.getSessionId()); + session.close(CLOSE_BAD_STATE); + } else { + log.debug("session={} unhandled frame type={}", session.getSessionId(), frame.getType()); + } + } + case CLOSED -> { /* drop */ } + } + } + + private void handleAuth(AgentSession session, AuthFrame frame) { + long now = System.currentTimeMillis(); + if (Math.abs(now - frame.getTimestamp()) > TIMESTAMP_SKEW_MS) { + log.warn("session={} timestamp skew {} ms (limit {})", + session.getSessionId(), now - frame.getTimestamp(), TIMESTAMP_SKEW_MS); + session.close(CLOSE_AUTH_FAILED); + return; + } + if (frame.getDeviceId() == null || frame.getSignature() == null) { + session.close(CLOSE_AUTH_FAILED); + return; + } + + Optional deviceOpt = deviceRepository.findById(frame.getDeviceId()); + if (deviceOpt.isEmpty()) { + log.warn("session={} unknown deviceId {}", session.getSessionId(), frame.getDeviceId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + Device device = deviceOpt.get(); + if (device.getRevokedAt() != null) { + log.warn("session={} revoked device {} attempting to auth", session.getSessionId(), device.getId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + if (device.getPublicKey() == null) { + log.error("session={} device {} has no public key on file", session.getSessionId(), device.getId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + + byte[] signed = AuthSignaturePayload.build( + session.getSessionId(), + session.getChallenge(), + frame.getDeviceId(), + frame.getTimestamp()); + byte[] signature; + try { + signature = Base64.getDecoder().decode(frame.getSignature()); + } catch (IllegalArgumentException e) { + session.close(CLOSE_AUTH_FAILED); + return; + } + + if (!ed25519.verify(device.getPublicKey(), signed, signature)) { + log.warn("session={} bad Ed25519 signature for device {}", session.getSessionId(), device.getId()); + session.close(CLOSE_AUTH_FAILED); + return; + } + + session.markAuthenticated(device.getId()); + registry.register(device.getId(), session); + log.info("session={} authenticated as device {}", session.getSessionId(), device.getId()); + + OutgoingAgentFrame ok = session.populateOutgoing(OutgoingAgentFrame.builder() + .type("auth_ok") + .deviceId(device.getId()) + .serverTime(now) + .heartbeatIntervalMs(HEARTBEAT_INTERVAL_MS)); + session.send(ok); + } + + private void handleHeartbeat(AgentSession session, HeartbeatFrame frame) { + try { + String remoteIp = session.getTransport().getRemoteAddress() == null + ? null + : session.getTransport().getRemoteAddress().getAddress().getHostAddress(); + heartbeatService.record(session.getDeviceId(), frame, remoteIp); + } catch (Exception e) { + log.error("session={} heartbeat persistence failed", session.getSessionId(), e); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession transport, CloseStatus status) { + AgentSession session = (AgentSession) transport.getAttributes().get(SESSION_ATTR); + if (session != null) { + session.markClosed(); + if (session.getDeviceId() != null) { + registry.unregister(session.getDeviceId(), session); + } + log.debug("session={} closed status={}", session.getSessionId(), status); + } + } + + private String newChallenge() { + byte[] raw = new byte[CHALLENGE_BYTES]; + random.nextBytes(raw); + return Base64.getEncoder().withoutPadding().encodeToString(raw); + } + + private static void safeClose(WebSocketSession transport, CloseStatus status) { + try { + if (transport.isOpen()) transport.close(status); + } catch (Exception ignored) { + // best effort + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index aff851c..542e249 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -88,6 +88,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/musallah/**").permitAll() .requestMatchers("/api/refresh-musallahboard").permitAll() .requestMatchers("/api/agent/enroll").permitAll() + .requestMatchers("/api/agent/ws").permitAll() .anyRequest().authenticated()); http.authenticationProvider(authenticationProvider()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java new file mode 100644 index 0000000..8627c17 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java @@ -0,0 +1,106 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Server-side state for one open agent WebSocket connection. + *

+ * A session moves through three phases: + *

    + *
  1. UNAUTH — connected, server has issued a {@code hello} with a challenge, + * but the client has not yet authenticated. Only an {@code auth} frame is allowed.
  2. + *
  3. AUTHED — Ed25519 challenge–response succeeded; {@link #deviceId} is bound.
  4. + *
  5. CLOSED — underlying transport closed.
  6. + *
+ */ +@Slf4j +public class AgentSession { + + public enum Phase { UNAUTH, AUTHED, CLOSED } + + @Getter private final UUID sessionId; + @Getter private final WebSocketSession transport; + @Getter private final String challenge; + + private final ObjectMapper objectMapper; + + private final AtomicLong outgoingSeq = new AtomicLong(0); + + /** Last seq received from the client (must be strictly increasing). 0 means none yet. */ + private long lastIncomingSeq = 0; + + @Getter private volatile Phase phase = Phase.UNAUTH; + @Getter private volatile UUID deviceId; + + public AgentSession(WebSocketSession transport, String challenge, ObjectMapper objectMapper) { + this.transport = transport; + this.objectMapper = objectMapper; + this.sessionId = UUID.randomUUID(); + this.challenge = challenge; + } + + /** Records that the next valid incoming seq is {@code seq + 1}. Returns false if this seq is a replay/regression. */ + public synchronized boolean acceptIncomingSeq(long seq) { + if (seq <= lastIncomingSeq) { + log.warn("session={} rejecting non-monotonic seq {} (last seen {})", sessionId, seq, lastIncomingSeq); + return false; + } + lastIncomingSeq = seq; + return true; + } + + public synchronized void markAuthenticated(UUID deviceId) { + if (phase != Phase.UNAUTH) { + throw new IllegalStateException("Session already past UNAUTH phase: " + phase); + } + this.deviceId = deviceId; + this.phase = Phase.AUTHED; + } + + public synchronized void markClosed() { + this.phase = Phase.CLOSED; + } + + /** Allocates the next outbound seq and returns the populated frame. */ + public OutgoingAgentFrame populateOutgoing(OutgoingAgentFrame.OutgoingAgentFrameBuilder builder) { + return builder.seq(outgoingSeq.incrementAndGet()).sessionId(sessionId).build(); + } + + public void send(OutgoingAgentFrame frame) { + try { + String json = objectMapper.writeValueAsString(frame); + synchronized (transport) { + if (transport.isOpen()) { + transport.sendMessage(new TextMessage(json)); + } + } + } catch (JsonProcessingException e) { + log.error("session={} failed to serialize frame {}", sessionId, frame.getType(), e); + close(CloseStatus.SERVER_ERROR); + } catch (IOException e) { + log.warn("session={} transport send failed: {}", sessionId, e.getMessage()); + close(CloseStatus.SESSION_NOT_RELIABLE); + } + } + + public void close(CloseStatus status) { + try { + if (transport.isOpen()) transport.close(status); + } catch (IOException ignored) { + // best effort + } finally { + markClosed(); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java new file mode 100644 index 0000000..e1bc2be --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java @@ -0,0 +1,45 @@ +package com.ibrasoft.lensbridge.service.agent; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks live, authenticated agent sessions keyed by deviceId. + *

+ * A device has at most one live session at a time: if a second session authenticates as + * the same device, the older one is evicted (assumed to be a stale connection from a + * NAT-rebound agent reconnecting before the previous TCP session timed out). + */ +@Component +@Slf4j +public class AgentSessionRegistry { + + private final ConcurrentHashMap byDevice = new ConcurrentHashMap<>(); + + public Optional get(UUID deviceId) { + return Optional.ofNullable(byDevice.get(deviceId)); + } + + public void register(UUID deviceId, AgentSession session) { + AgentSession previous = byDevice.put(deviceId, session); + if (previous != null && previous != session) { + log.info("Evicting prior session for device {} (sessionId {}) in favour of {}", + deviceId, previous.getSessionId(), session.getSessionId()); + previous.close(CloseStatus.NORMAL.withReason("superseded")); + } + } + + /** Removes the session if it is still the registered one for this device. */ + public void unregister(UUID deviceId, AgentSession session) { + byDevice.remove(deviceId, session); + } + + public int size() { + return byDevice.size(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java new file mode 100644 index 0000000..0935721 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java @@ -0,0 +1,59 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceTelemetryRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.UUID; + +/** + * Persists incoming heartbeat frames: bumps {@link Device#getLastHeartbeat()} and + * appends a {@link DeviceTelemetry} row. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class HeartbeatService { + + private final DeviceRepository deviceRepository; + private final DeviceTelemetryRepository telemetryRepository; + + @Transactional + public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { + Instant now = Instant.now(); + + deviceRepository.findById(deviceId).ifPresent(device -> { + device.setLastHeartbeat(now); + if (remoteIp != null && !remoteIp.isBlank()) { + device.setLastSeenIp(remoteIp); + } + deviceRepository.save(device); + }); + + HeartbeatFrame.Telemetry t = frame.getTelemetry(); + if (t == null) return; + + DeviceTelemetry row = DeviceTelemetry.builder() + .deviceId(deviceId) + .recordedAt(now) + .uptimeSec(t.getUptimeSec()) + .cpuTempC(t.getCpuTempC()) + .throttleFlags(t.getThrottleFlags()) + .memUsedMb(t.getMemUsedMb()) + .memTotalMb(t.getMemTotalMb()) + .diskUsedPct(t.getDiskUsedPct()) + .kioskAlive(t.getKioskAlive()) + .ipv4(t.getIpv4() == null ? null : String.join(",", t.getIpv4())) + .wifiSsid(t.getWifiSsid()) + .displayedFrameId(t.getDisplayedFrameId()) + .build(); + telemetryRepository.save(row); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java new file mode 100644 index 0000000..1f7792e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/handshake/AuthSignaturePayload.java @@ -0,0 +1,35 @@ +package com.ibrasoft.lensbridge.service.agent.handshake; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * Canonical byte sequence the agent signs at handshake time. + *

+ * Both sides MUST construct it identically — the protocol version prefix lets us + * change the format in the future without breaking older agents (they will simply + * fail verification, which is the correct behaviour). + * + *

+ * musallahboard-auth-v1\n
+ * <sessionId>\n
+ * <challenge-base64>\n
+ * <deviceId>\n
+ * <timestamp-millis>
+ * 
+ */ +public final class AuthSignaturePayload { + + public static final String VERSION_PREFIX = "musallahboard-auth-v1"; + + private AuthSignaturePayload() {} + + public static byte[] build(UUID sessionId, String challengeBase64, UUID deviceId, long timestampMillis) { + String s = VERSION_PREFIX + "\n" + + sessionId + "\n" + + challengeBase64 + "\n" + + deviceId + "\n" + + timestampMillis; + return s.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java new file mode 100644 index 0000000..b305d3d --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java @@ -0,0 +1,305 @@ +package com.ibrasoft.lensbridge.handler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.Ed25519TestUtil; +import com.ibrasoft.lensbridge.service.agent.HeartbeatService; +import com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload; +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.security.KeyPair; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AgentWebSocketHandlerTest { + + private ObjectMapper mapper; + private DeviceRepository deviceRepo; + private AgentSessionRegistry registry; + private HeartbeatService heartbeatService; + private AgentWebSocketHandler handler; + + @BeforeEach + void setUp() { + mapper = new ObjectMapper(); + deviceRepo = mock(DeviceRepository.class); + registry = new AgentSessionRegistry(); + heartbeatService = mock(HeartbeatService.class); + handler = new AgentWebSocketHandler(mapper, new Ed25519Verifier(), deviceRepo, registry, heartbeatService); + } + + @Test + void afterConnectionEstablished_sendsHello() throws Exception { + FakeSession s = new FakeSession(); + + handler.afterConnectionEstablished(s); + + assertEquals(1, s.outbox.size()); + JsonNode hello = mapper.readTree(s.outbox.get(0)); + assertEquals("hello", hello.get("type").asText()); + assertEquals(1, hello.get("seq").asLong()); + assertNotNull(UUID.fromString(hello.get("sessionId").asText())); + assertTrue(hello.get("challenge").asText().length() >= 40, "expected base64-encoded 32 random bytes"); + assertTrue(hello.get("serverTime").asLong() > 0); + } + + @Test + void auth_happyPath_sendsAuthOkAndRegisters() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + long now = System.currentTimeMillis(); + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), now, kp); + + assertTrue(s.isOpen, "session should remain open after successful auth"); + assertEquals(2, s.outbox.size(), "expected auth_ok in addition to hello"); + JsonNode ok = mapper.readTree(s.outbox.get(1)); + assertEquals("auth_ok", ok.get("type").asText()); + assertEquals(2, ok.get("seq").asLong()); + assertEquals(device.getId().toString(), ok.get("deviceId").asText()); + assertEquals(30_000, ok.get("heartbeatIntervalMs").asInt()); + + assertTrue(registry.get(device.getId()).isPresent(), "registry should hold the new session"); + } + + @Test + void auth_badSignature_closes4001() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + long now = System.currentTimeMillis(); + // Sign the WRONG message (different challenge). + byte[] wrongPayload = AuthSignaturePayload.build(hello.sessionId, "different-challenge", device.getId(), now); + String wrongSig = Base64.getEncoder().encodeToString(Ed25519TestUtil.sign(kp.getPrivate(), wrongPayload)); + + deliver(s, authJson(hello.sessionId, device.getId(), now, wrongSig)); + + assertClosed(s, 4001); + assertTrue(registry.get(device.getId()).isEmpty()); + } + + @Test + void auth_expiredTimestamp_closes4001() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + long stale = System.currentTimeMillis() - (10 * 60 * 1000L); // 10 minutes ago + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), stale, kp); + + assertClosed(s, 4001); + } + + @Test + void auth_revokedDevice_closes4001() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + device.setRevokedAt(java.time.Instant.now().minusSeconds(60)); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), System.currentTimeMillis(), kp); + + assertClosed(s, 4001); + } + + @Test + void auth_unknownDevice_closes4001() throws Exception { + when(deviceRepo.findById(any())).thenReturn(Optional.empty()); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + KeyPair kp = Ed25519TestUtil.generate(); + deliverAuth(s, hello.sessionId, hello.challenge, UUID.randomUUID(), System.currentTimeMillis(), kp); + + assertClosed(s, 4001); + } + + @Test + void auth_sessionIdMismatch_closes4001() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + // Defends against an attacker replaying a frame captured from a different session. + UUID forgedSessionId = UUID.randomUUID(); + long now = System.currentTimeMillis(); + byte[] payload = AuthSignaturePayload.build(forgedSessionId, hello.challenge, device.getId(), now); + String sig = Base64.getEncoder().encodeToString(Ed25519TestUtil.sign(kp.getPrivate(), payload)); + + deliver(s, authJson(forgedSessionId, device.getId(), now, sig)); + + assertClosed(s, 4001); + } + + @Test + void duplicateSeq_closes4002() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + + long now = System.currentTimeMillis(); + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), now, kp); + assertTrue(s.isOpen, "auth should have succeeded first"); + + String hbReplay = String.format( + "{\"type\":\"heartbeat\",\"seq\":1,\"sessionId\":\"%s\",\"telemetry\":{}}", + hello.sessionId); + deliver(s, hbReplay); + + assertClosed(s, 4002); + } + + @Test + void heartbeat_persistsViaService() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), System.currentTimeMillis(), kp); + + String hb = String.format( + "{\"type\":\"heartbeat\",\"seq\":2,\"sessionId\":\"%s\"," + + "\"telemetry\":{\"uptimeSec\":42,\"cpuTempC\":50.5,\"kioskAlive\":true}}", + hello.sessionId); + deliver(s, hb); + + verify(heartbeatService).record(eq(device.getId()), any(), any()); + assertTrue(s.isOpen, "heartbeat should not close session"); + } + + @Test + void afterClose_unregistersFromRegistry() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), System.currentTimeMillis(), kp); + + assertTrue(registry.get(device.getId()).isPresent()); + handler.afterConnectionClosed(s, CloseStatus.NORMAL); + assertTrue(registry.get(device.getId()).isEmpty()); + } + + // ────────────────────────────────────────────────────────────────────── + // helpers + // ────────────────────────────────────────────────────────────────────── + + private FakeSession openSession() throws Exception { + FakeSession s = new FakeSession(); + handler.afterConnectionEstablished(s); + return s; + } + + private Device persistedDeviceWith(byte[] rawPublicKey) { + Device device = Device.builder() + .id(UUID.randomUUID()) + .displayName("test") + .audience(Audience.BOTH) + .publicKey(rawPublicKey) + .build(); + when(deviceRepo.findById(device.getId())).thenReturn(Optional.of(device)); + return device; + } + + private record Hello(UUID sessionId, String challenge) {} + + private Hello parseHello(String json) throws Exception { + JsonNode node = mapper.readTree(json); + return new Hello( + UUID.fromString(node.get("sessionId").asText()), + node.get("challenge").asText() + ); + } + + private void deliverAuth(FakeSession s, UUID sessionId, String challenge, UUID deviceId, long timestamp, KeyPair kp) { + byte[] payload = AuthSignaturePayload.build(sessionId, challenge, deviceId, timestamp); + String sig = Base64.getEncoder().encodeToString(Ed25519TestUtil.sign(kp.getPrivate(), payload)); + deliver(s, authJson(sessionId, deviceId, timestamp, sig)); + } + + private void deliver(FakeSession s, String json) { + // handleTextMessage is protected; same package access. + handler.handleTextMessage(s, new TextMessage(json)); + } + + private String authJson(UUID sessionId, UUID deviceId, long timestamp, String signatureBase64) { + return String.format( + "{\"type\":\"auth\",\"seq\":1,\"sessionId\":\"%s\",\"deviceId\":\"%s\",\"timestamp\":%d,\"signature\":\"%s\"}", + sessionId, deviceId, timestamp, signatureBase64); + } + + private void assertClosed(FakeSession s, int expectedCode) { + assertFalse(s.isOpen, "session should be closed"); + assertNotNull(s.closeStatus, "expected close status to be set"); + assertEquals(expectedCode, s.closeStatus.getCode(), + "expected close code " + expectedCode + " but got " + s.closeStatus); + } + + /** Hand-rolled WebSocketSession to avoid deep-stub mocks. */ + static class FakeSession implements WebSocketSession { + final Map attributes = new HashMap<>(); + final List outbox = new ArrayList<>(); + boolean isOpen = true; + CloseStatus closeStatus; + final String id = UUID.randomUUID().toString(); + + @Override public String getId() { return id; } + @Override public java.net.URI getUri() { return null; } + @Override public org.springframework.http.HttpHeaders getHandshakeHeaders() { return new org.springframework.http.HttpHeaders(); } + @Override public Map getAttributes() { return attributes; } + @Override public java.security.Principal getPrincipal() { return null; } + @Override public java.net.InetSocketAddress getLocalAddress() { return null; } + @Override public java.net.InetSocketAddress getRemoteAddress() { return null; } + @Override public String getAcceptedProtocol() { return null; } + @Override public void setTextMessageSizeLimit(int messageSizeLimit) {} + @Override public int getTextMessageSizeLimit() { return 0; } + @Override public void setBinaryMessageSizeLimit(int messageSizeLimit) {} + @Override public int getBinaryMessageSizeLimit() { return 0; } + @Override public List getExtensions() { return List.of(); } + @Override public void sendMessage(WebSocketMessage message) { + if (message instanceof TextMessage tm) outbox.add(tm.getPayload()); + } + @Override public boolean isOpen() { return isOpen; } + @Override public void close() { close(CloseStatus.NORMAL); } + @Override public void close(CloseStatus status) { + this.isOpen = false; + this.closeStatus = status; + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519TestUtil.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519TestUtil.java new file mode 100644 index 0000000..55b7334 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/Ed25519TestUtil.java @@ -0,0 +1,49 @@ +package com.ibrasoft.lensbridge.service.agent; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.interfaces.EdECPublicKey; + +/** + * Shared helpers for tests that need to generate Ed25519 keypairs and produce + * raw 32-byte public-key bytes matching the on-the-wire format. + */ +public final class Ed25519TestUtil { + + private Ed25519TestUtil() {} + + public static KeyPair generate() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public static byte[] rawPublicKey(PublicKey publicKey) { + EdECPublicKey ed = (EdECPublicKey) publicKey; + byte[] y = ed.getPoint().getY().toByteArray(); + byte[] le = new byte[32]; + for (int i = 0; i < y.length && i < 32; i++) { + le[i] = y[y.length - 1 - i]; + } + if (ed.getPoint().isXOdd()) { + le[31] |= (byte) 0x80; + } + return le; + } + + public static byte[] sign(PrivateKey privateKey, byte[] message) { + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(privateKey); + signer.update(message); + return signer.sign(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} From 9bc8186c7b4a9bf880d899b71e9de06fcedd8543 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 13:09:38 -0400 Subject: [PATCH 05/52] Updated repositories to PostgreSQL --- .../repository/EventRepository.java | 44 ------------------ .../repository/PosterRepository.java | 46 ------------------- .../repository/WeeklyContentRepository.java | 22 --------- .../{ => mongo}/AuditEventRepository.java | 0 .../{ => mongo}/EventsRepository.java | 0 .../{ => mongo}/RefreshTokenRepository.java | 0 .../{ => mongo}/UploadRepository.java | 0 .../{ => mongo}/UserRepository.java | 0 8 files changed, 112 deletions(-) delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java rename src/main/java/com/ibrasoft/lensbridge/repository/{ => mongo}/AuditEventRepository.java (100%) rename src/main/java/com/ibrasoft/lensbridge/repository/{ => mongo}/EventsRepository.java (100%) rename src/main/java/com/ibrasoft/lensbridge/repository/{ => mongo}/RefreshTokenRepository.java (100%) rename src/main/java/com/ibrasoft/lensbridge/repository/{ => mongo}/UploadRepository.java (100%) rename src/main/java/com/ibrasoft/lensbridge/repository/{ => mongo}/UserRepository.java (100%) diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java deleted file mode 100644 index dca984f..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/EventRepository.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.Event; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.UUID; - -@Repository -public interface EventRepository extends MongoRepository { - - /** - * Find all events for a specific audience or BOTH. - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] } }") - List findByAudienceOrBoth(Audience audience, Sort sort); - - /** - * Find events whose audience matches the given audience or BOTH, AND whose time interval - * [startTimestamp, endTimestamp] overlaps the given range [rangeStart, rangeEnd]. - * - * Overlap semantics: event.startTimestamp <= rangeEnd AND event.endTimestamp >= rangeStart. - * (An event overlaps the range iff it starts before the range ends and ends after the range starts.) - * - * Parameter indices: ?0 = audience, ?1 = rangeStart, ?2 = rangeEnd. - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] }, 'startTimestamp': { $lte: ?2 }, 'endTimestamp': { $gte: ?1 } }") - List findOverlappingForAudienceOrBoth(Audience audience, long rangeStart, long rangeEnd, Sort sort); - - /** - * Find all upcoming events for a specific audience or BOTH (startTimestamp >= now). - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] }, 'startTimestamp': { $gte: ?1 } }") - List findUpcomingByAudienceOrBoth(Audience audience, long nowTimestamp, Sort sort); - - /** - * Find all events sorted by startTimestamp ascending. - */ - List findAllByOrderByStartTimestampAsc(); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java deleted file mode 100644 index 5b13480..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/PosterRepository.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.Poster; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.stereotype.Repository; - -import java.time.LocalDate; -import java.util.List; -import java.util.UUID; - -@Repository -public interface PosterRepository extends MongoRepository { - - /** - * Find all posters active at a given date. - */ - @Query("{ 'startDate': { $lte: ?0 }, 'endDate': { $gt: ?0 } }") - List findActivePostersAt(LocalDate date); - - /** - * Find all posters for a specific audience or BOTH, active at a given date. - * Sorted by startDate descending (newest first). - */ - @Query("{ 'startDate': { $lte: ?0 }, 'endDate': { $gt: ?0 }, 'audience': { $in: [?1, 'BOTH'] } }") - List findActivePostersForAudienceAt(LocalDate date, Audience audience, Sort sort); - - /** - * Find all posters for a specific audience or BOTH. - */ - @Query("{ 'audience': { $in: [?0, 'BOTH'] } }") - List findByAudienceOrBoth(Audience audience, Sort sort); - - /** - * Find all posters sorted by startDate descending (newest first). - */ - List findAllByOrderByStartDateDesc(); - - /** - * Find all posters for a specific audience. - */ - List findByAudience(Audience audience, Sort sort); - -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java deleted file mode 100644 index 4a597d1..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/WeeklyContentRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ibrasoft.lensbridge.repository; - -import com.ibrasoft.lensbridge.model.board.WeekId; -import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface WeeklyContentRepository extends MongoRepository { - - /** - * Find weekly content by year. - */ - List findByWeekIdYear(Integer year); - - /** - * Find weekly content by year and week number. - */ - List findByWeekIdYearAndWeekIdWeekNumber(Integer year, Integer weekNumber); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java similarity index 100% rename from src/main/java/com/ibrasoft/lensbridge/repository/AuditEventRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java similarity index 100% rename from src/main/java/com/ibrasoft/lensbridge/repository/EventsRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/RefreshTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java similarity index 100% rename from src/main/java/com/ibrasoft/lensbridge/repository/RefreshTokenRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java similarity index 100% rename from src/main/java/com/ibrasoft/lensbridge/repository/UploadRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java similarity index 100% rename from src/main/java/com/ibrasoft/lensbridge/repository/UserRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java From 41db7d583ef2ef3cc72223e4367c186b0bbf6f83 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 13:09:48 -0400 Subject: [PATCH 06/52] Finalized STOMP + Websocket + new MusallahBoard Agent protocol. --- .../lensbridge/LensBridgeApplication.java | 2 + .../lensbridge/config/WebSocketConfig.java | 13 + .../lensbridge/config/stomp/StompConfig.java | 61 +++++ .../stomp/StompJwtChannelInterceptor.java | 66 +++++ .../controller/AgentEnrollmentController.java | 2 +- .../controller/DeviceAdminController.java | 31 +++ .../lensbridge/dto/agent/CommandAckFrame.java | 13 + .../dto/agent/CommandProgressFrame.java | 17 ++ .../dto/agent/CommandResultFrame.java | 22 ++ .../dto/agent/IncomingAgentFrame.java | 7 +- .../dto/agent/OutgoingAgentFrame.java | 8 + .../dto/request/IssueCommandRequest.java | 20 ++ .../dto/response/CommandIssuedResponse.java | 14 ++ .../lensbridge/dto/response/CommandView.java | 55 +++++ .../handler/AgentWebSocketHandler.java | 22 ++ .../board/commands/ChromeReloadPayload.java | 6 + .../commands/ChromeScreenshotPayload.java | 6 + .../model/board/commands/CommandPayload.java | 31 +++ .../board/commands/ConfigRefreshPayload.java | 6 + .../board/commands/KioskRestartPayload.java | 6 + .../model/board/commands/LogsTailPayload.java | 11 + .../board/commands/SystemRebootPayload.java | 6 + .../mongo/AuditEventRepository.java | 2 +- .../repository/mongo/EventsRepository.java | 2 +- .../mongo/RefreshTokenRepository.java | 2 +- .../repository/mongo/UploadRepository.java | 2 +- .../repository/mongo/UserRepository.java | 2 +- .../security/WebSecurityConfig.java | 1 + .../services/UserDetailsServiceImpl.java | 2 +- .../lensbridge/service/AdminAuditService.java | 2 +- .../lensbridge/service/EventsService.java | 2 +- .../lensbridge/service/GalleryService.java | 4 +- .../service/RefreshTokenService.java | 2 +- .../service/ThumbnailBackfillService.java | 2 +- .../lensbridge/service/ThumbnailService.java | 2 +- .../lensbridge/service/UploadService.java | 2 +- .../lensbridge/service/UserService.java | 2 +- .../service/agent/AgentSession.java | 8 +- .../service/agent/AgentSessionRegistry.java | 8 + .../service/agent/CommandDispatcher.java | 219 ++++++++++++++++ .../agent/DeviceEnrollmentService.java | 4 +- .../service/agent/HeartbeatService.java | 4 + .../agent/events/DeviceEventPublisher.java | 116 +++++++++ .../controller/DeviceAdminControllerTest.java | 91 +++++++ .../handler/AgentWebSocketHandlerTest.java | 10 +- .../lensbridge/service/EventServiceTests.java | 2 +- .../service/agent/CommandDispatcherTest.java | 233 ++++++++++++++++++ 47 files changed, 1129 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java index 7a2b3b3..b9bdc6f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java +++ b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableAsync @EnableJpaRepositories(basePackages = "com.ibrasoft.lensbridge.repository.sql") +@EnableMongoRepositories(basePackages = "com.ibrasoft.lensbridge.repository.mongo") public class LensBridgeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java index 3266709..a642f6f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java @@ -9,6 +9,8 @@ import com.ibrasoft.lensbridge.handler.AgentWebSocketHandler; import com.ibrasoft.lensbridge.handler.SignboardHandler; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; @Configuration @EnableWebSocket @@ -38,4 +40,15 @@ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(agentWebSocketHandler, "/api/agent/ws") .setAllowedOrigins("*"); } + + @Bean + public ServletServerContainerFactoryBean webSocketContainer() { + ServletServerContainerFactoryBean container = + new ServletServerContainerFactoryBean(); + + container.setMaxTextMessageBufferSize(10 * 1024 * 1024); // 10 MB + container.setMaxBinaryMessageBufferSize(10 * 1024 * 1024); + + return container; + } } \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java new file mode 100644 index 0000000..715c3dc --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompConfig.java @@ -0,0 +1,61 @@ +package com.ibrasoft.lensbridge.config.stomp; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +/** + * STOMP-over-WebSocket endpoint at {@code /api/dashboard/ws} for the admin dashboard. + *

+ * Topics published by the backend: + *

    + *
  • {@code /topic/devices/{id}} — heartbeat + online/offline transitions
  • + *
  • {@code /topic/devices/{id}/commands/{cmdId}} — per-command lifecycle events
  • + *
  • {@code /topic/devices/{id}/commands} — fan-out of all command events for a device
  • + *
+ * Authentication is enforced by {@link StompJwtChannelInterceptor} on the + * {@code clientInboundChannel} — every STOMP {@code CONNECT} must carry a valid JWT. + */ +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class StompConfig implements WebSocketMessageBrokerConfigurer { + + private final StompJwtChannelInterceptor jwtInterceptor; + + @Value("${frontend.baseurl}") + private String frontendBaseUrl; + + @Value("${musallahboard.baseurl}") + private String musallahBoardBaseUrl; + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/api/dashboard/ws") + .setAllowedOrigins(frontendBaseUrl, musallahBoardBaseUrl); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(jwtInterceptor); + } + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setMessageSizeLimit(10 * 1024 * 1024); // inbound message size + registry.setSendBufferSizeLimit(10 * 1024 * 1024); // outbound buffer + registry.setSendTimeLimit(20_000); // optional + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java new file mode 100644 index 0000000..6cb1427 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java @@ -0,0 +1,66 @@ +package com.ibrasoft.lensbridge.config.stomp; + +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +/** + * Validates the JWT carried in the STOMP {@code CONNECT} frame's {@code Authorization} + * native header. Subsequent SUBSCRIBE / SEND frames inherit the resolved principal via + * {@link StompHeaderAccessor#setUser(java.security.Principal)}. + *

+ * Browsers cannot set arbitrary headers on the WebSocket HTTP upgrade, so we authenticate + * at the STOMP layer rather than the HTTP handshake. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class StompJwtChannelInterceptor implements ChannelInterceptor { + + private final JwtUtils jwtUtils; + private final UserDetailsServiceImpl userDetailsService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null || !StompCommand.CONNECT.equals(accessor.getCommand())) { + return message; + } + + String header = accessor.getFirstNativeHeader("Authorization"); + String token = stripBearer(header); + if (token == null) { + log.warn("STOMP CONNECT rejected: missing Authorization header"); + throw new IllegalArgumentException("Missing Authorization header on STOMP CONNECT"); + } + if (!jwtUtils.validateJwtToken(token)) { + log.warn("STOMP CONNECT rejected: invalid JWT"); + throw new IllegalArgumentException("Invalid JWT on STOMP CONNECT"); + } + + String username = jwtUtils.getUserNameFromJwtToken(token); + UserDetails user = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + accessor.setUser(auth); + log.debug("STOMP authenticated as {}", username); + return message; + } + + private static String stripBearer(String header) { + if (header == null) return null; + if (header.regionMatches(true, 0, "Bearer ", 0, 7)) { + return header.substring(7).trim(); + } + return header.trim(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java index 13c7472..9a6e602 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -31,7 +31,7 @@ public class AgentEnrollmentController { private final DeviceEnrollmentService enrollmentService; - @Value("${musallahboard.agent.websocketUrl:}") + @Value("${musallahboard.agent.websocketUrl:ws://10.0.0.75/api/agent/ws}") private String websocketUrl; @PostMapping("/enroll") diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index f55e833..9784360 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -1,13 +1,20 @@ package com.ibrasoft.lensbridge.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.request.IssueCommandRequest; import com.ibrasoft.lensbridge.dto.request.IssueEnrollmentTokenRequest; +import com.ibrasoft.lensbridge.dto.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.dto.response.CommandView; import com.ibrasoft.lensbridge.dto.response.DeviceSummary; import com.ibrasoft.lensbridge.dto.response.IssueEnrollmentTokenResponse; import com.ibrasoft.lensbridge.dto.response.MessageResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService.Issued; import jakarta.validation.Valid; @@ -18,6 +25,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import org.springframework.web.socket.CloseStatus; import java.time.Instant; import java.util.List; @@ -33,6 +41,10 @@ public class DeviceAdminController { private final EnrollmentTokenService enrollmentTokenService; private final DeviceRepository deviceRepository; + private final DeviceCommandRepository commandRepository; + private final CommandDispatcher commandDispatcher; + private final ObjectMapper objectMapper; + private final AgentSessionRegistry sessionRegistry; @GetMapping public ResponseEntity> list() { @@ -81,11 +93,30 @@ public ResponseEntity revoke(@PathVariable UUID deviceId) { if (d.getRevokedAt() == null) { d.setRevokedAt(Instant.now()); deviceRepository.save(d); + sessionRegistry.closeIfPresent(deviceId, CloseStatus.POLICY_VIOLATION.withReason("device_revoked")); log.warn("Device {} revoked by {}", deviceId, currentUser().getEmail()); } return ResponseEntity.ok(DeviceSummary.of(d)); } + @PostMapping("/{deviceId}/commands") + public ResponseEntity issueCommand(@PathVariable UUID deviceId, + @Valid @RequestBody IssueCommandRequest request) { + UserDetailsImpl admin = currentUser(); + CommandIssuedResponse response = commandDispatcher.issue(deviceId, admin.getEmail(), request); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + @GetMapping("/{deviceId}/commands") + public ResponseEntity> recentCommands(@PathVariable UUID deviceId) { + List commands = commandRepository + .findTop50ByDeviceIdOrderByIssuedAtDesc(deviceId) + .stream() + .map(c -> CommandView.of(c, objectMapper)) + .toList(); + return ResponseEntity.ok(commands); + } + private UserDetailsImpl currentUser() { return (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java new file mode 100644 index 0000000..3410ed0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** Agent → backend: "I received and accepted command {@code commandId}, will execute". */ +@Getter +@Setter +public class CommandAckFrame extends IncomingAgentFrame { + private UUID commandId; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java new file mode 100644 index 0000000..ca2bfe2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java @@ -0,0 +1,17 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** Agent → backend: optional progress update for a long-running command. */ +@Getter +@Setter +public class CommandProgressFrame extends IncomingAgentFrame { + private UUID commandId; + private String stage; + private String message; + /** 0..100, optional. */ + private Integer percent; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java new file mode 100644 index 0000000..5bf3931 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.dto.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** + * Agent → backend: terminal frame for a command. {@link #status} is one of + * {@code "ok"}, {@code "error"}, {@code "timeout"}, {@code "rejected"} (free-form + * to keep the wire flexible; the backend maps to {@link com.ibrasoft.lensbridge.model.board.DeviceCommandStatus}). + */ +@Getter +@Setter +public class CommandResultFrame extends IncomingAgentFrame { + private UUID commandId; + private String status; + private JsonNode output; + private String errorMessage; + private Long durationMs; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java index 5a0a1ba..226797a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java @@ -16,8 +16,11 @@ visible = true ) @JsonSubTypes({ - @JsonSubTypes.Type(value = AuthFrame.class, name = "auth"), - @JsonSubTypes.Type(value = HeartbeatFrame.class, name = "heartbeat") + @JsonSubTypes.Type(value = AuthFrame.class, name = "auth"), + @JsonSubTypes.Type(value = HeartbeatFrame.class, name = "heartbeat"), + @JsonSubTypes.Type(value = CommandAckFrame.class, name = "command_ack"), + @JsonSubTypes.Type(value = CommandProgressFrame.class, name = "command_progress"), + @JsonSubTypes.Type(value = CommandResultFrame.class, name = "command_result") }) public abstract class IncomingAgentFrame { diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java index e5736cb..e218a1e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java @@ -28,6 +28,14 @@ public class OutgoingAgentFrame { private UUID deviceId; private Integer heartbeatIntervalMs; + // ── command ────────────────────────────────────────────────────────── + private UUID commandId; + private String kind; + private String issuedBy; + private Integer deadlineMs; + /** Serialized command payload (typically a {@link com.fasterxml.jackson.databind.JsonNode}). */ + private Object payload; + // ── error ──────────────────────────────────────────────────────────── private String error; private String reason; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java new file mode 100644 index 0000000..e7a7dbd --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java @@ -0,0 +1,20 @@ +package com.ibrasoft.lensbridge.dto.request; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Admin request to issue a command at a single device. + *

+ * {@code kind} is the polymorphic discriminator (must match a registered + * {@link com.ibrasoft.lensbridge.model.board.commands.CommandPayload} subtype). + * {@code payload} is the kind-specific JSON body (may be empty for parameterless commands). + */ +public record IssueCommandRequest( + @NotBlank + @Pattern(regexp = "[a-z]+\\.[a-z_]+", message = "kind must be in dotted lowercase form, e.g. chrome.reload") + String kind, + JsonNode payload, + Integer deadlineMs +) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java new file mode 100644 index 0000000..9ff2beb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java @@ -0,0 +1,14 @@ +package com.ibrasoft.lensbridge.dto.response; + +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; + +import java.time.Instant; +import java.util.UUID; + +public record CommandIssuedResponse( + UUID commandId, + UUID deviceId, + String kind, + DeviceCommandStatus status, + Instant issuedAt +) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java new file mode 100644 index 0000000..e2c1c9f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java @@ -0,0 +1,55 @@ +package com.ibrasoft.lensbridge.dto.response; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; + +import java.time.Instant; +import java.util.UUID; + +/** Read-only view of a {@link DeviceCommand} for admin REST responses. */ +public record CommandView( + UUID id, + UUID deviceId, + String kind, + JsonNode payload, + String issuedBy, + Integer deadlineMs, + DeviceCommandStatus status, + Instant issuedAt, + Instant deliveredAt, + Instant ackedAt, + Instant startedAt, + Instant finishedAt, + JsonNode output, + String errorMessage +) { + public static CommandView of(DeviceCommand cmd, ObjectMapper mapper) { + return new CommandView( + cmd.getId(), + cmd.getDeviceId(), + cmd.getKind(), + parseOrNull(cmd.getPayloadJson(), mapper), + cmd.getIssuedBy(), + cmd.getDeadlineMs(), + cmd.getStatus(), + cmd.getIssuedAt(), + cmd.getDeliveredAt(), + cmd.getAckedAt(), + cmd.getStartedAt(), + cmd.getFinishedAt(), + parseOrNull(cmd.getOutputJson(), mapper), + cmd.getErrorMessage() + ); + } + + private static JsonNode parseOrNull(String json, ObjectMapper mapper) { + if (json == null || json.isBlank()) return null; + try { + return mapper.readTree(json); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java index 697b0a9..3b92bb1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java @@ -2,6 +2,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ibrasoft.lensbridge.dto.agent.AuthFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; import com.ibrasoft.lensbridge.dto.agent.IncomingAgentFrame; import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; @@ -9,7 +12,9 @@ import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.agent.AgentSession; import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; import com.ibrasoft.lensbridge.service.agent.HeartbeatService; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; import com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload; import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; import lombok.RequiredArgsConstructor; @@ -56,6 +61,8 @@ public class AgentWebSocketHandler extends TextWebSocketHandler { private final DeviceRepository deviceRepository; private final AgentSessionRegistry registry; private final HeartbeatService heartbeatService; + private final CommandDispatcher commandDispatcher; + private final DeviceEventPublisher events; private final SecureRandom random = new SecureRandom(); @Override @@ -110,6 +117,12 @@ protected void handleTextMessage(WebSocketSession transport, TextMessage message case AUTHED -> { if (frame instanceof HeartbeatFrame hb) { handleHeartbeat(session, hb); + } else if (frame instanceof CommandAckFrame ack) { + commandDispatcher.onAck(ack, session); + } else if (frame instanceof CommandProgressFrame progress) { + commandDispatcher.onProgress(progress, session); + } else if (frame instanceof CommandResultFrame result) { + commandDispatcher.onResult(result, session); } else if (frame instanceof AuthFrame) { log.warn("session={} duplicate auth after auth_ok", session.getSessionId()); session.close(CLOSE_BAD_STATE); @@ -181,6 +194,14 @@ private void handleAuth(AgentSession session, AuthFrame frame) { .serverTime(now) .heartbeatIntervalMs(HEARTBEAT_INTERVAL_MS)); session.send(ok); + + events.deviceOnline(device.getId()); + try { + commandDispatcher.flushPending(device.getId(), session); + } catch (Exception e) { + log.error("session={} failed to flush pending commands for device {}", + session.getSessionId(), device.getId(), e); + } } private void handleHeartbeat(AgentSession session, HeartbeatFrame frame) { @@ -201,6 +222,7 @@ public void afterConnectionClosed(WebSocketSession transport, CloseStatus status session.markClosed(); if (session.getDeviceId() != null) { registry.unregister(session.getDeviceId(), session); + events.deviceOffline(session.getDeviceId()); } log.debug("session={} closed status={}", session.getSessionId(), status); } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java new file mode 100644 index 0000000..418cacd --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeReloadPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Force-reload the kiosk Chrome page via CDP {@code Page.reload(ignoreCache:true)}. */ +public record ChromeReloadPayload() implements CommandPayload { + @Override public String kind() { return "chrome.reload"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java new file mode 100644 index 0000000..3485ed9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ChromeScreenshotPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Capture the current kiosk frame via CDP {@code Page.captureScreenshot}. PNG, full viewport. */ +public record ChromeScreenshotPayload() implements CommandPayload { + @Override public String kind() { return "chrome.screenshot"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java new file mode 100644 index 0000000..13ddfd3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandPayload.java @@ -0,0 +1,31 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Polymorphic command payload, mirroring the {@link com.ibrasoft.lensbridge.model.board.frames.FrameConfig} + * pattern: adding a new command kind = new record + new {@code @Type} entry + agent-side handler. + *

+ * The discriminator is {@code kind} (e.g. {@code "chrome.reload"}). Stored as JSON in + * {@code device_commands.payload_json} and re-parsed when delivering to the agent. + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "kind" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = ChromeReloadPayload.class, name = "chrome.reload"), + @JsonSubTypes.Type(value = ChromeScreenshotPayload.class, name = "chrome.screenshot"), + @JsonSubTypes.Type(value = KioskRestartPayload.class, name = "kiosk.restart"), + @JsonSubTypes.Type(value = SystemRebootPayload.class, name = "system.reboot"), + @JsonSubTypes.Type(value = ConfigRefreshPayload.class, name = "config.refresh"), + @JsonSubTypes.Type(value = LogsTailPayload.class, name = "logs.tail"), +}) +public sealed interface CommandPayload + permits ChromeReloadPayload, ChromeScreenshotPayload, KioskRestartPayload, + SystemRebootPayload, ConfigRefreshPayload, LogsTailPayload { + + String kind(); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java new file mode 100644 index 0000000..5e434e8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/ConfigRefreshPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Trigger an in-page re-fetch of the board payload (no full reload). */ +public record ConfigRefreshPayload() implements CommandPayload { + @Override public String kind() { return "config.refresh"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java new file mode 100644 index 0000000..4720723 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/KioskRestartPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Restart the {@code musallahboard-kiosk.service} systemd unit on the Pi. */ +public record KioskRestartPayload() implements CommandPayload { + @Override public String kind() { return "kiosk.restart"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java new file mode 100644 index 0000000..20270ca --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/LogsTailPayload.java @@ -0,0 +1,11 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Tail the last {@code lines} entries from the agent + kiosk journald units. Capped at 500. */ +public record LogsTailPayload(Integer lines) implements CommandPayload { + public LogsTailPayload { + if (lines != null && (lines < 1 || lines > 500)) { + throw new IllegalArgumentException("lines must be between 1 and 500"); + } + } + @Override public String kind() { return "logs.tail"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java new file mode 100644 index 0000000..8800261 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/SystemRebootPayload.java @@ -0,0 +1,6 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +/** Reboot the host. Agent acks + reports {@code ok} *before* invoking shutdown. */ +public record SystemRebootPayload() implements CommandPayload { + @Override public String kind() { return "system.reboot"; } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java index 9660c10..ac1fe38 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.repository; +package com.ibrasoft.lensbridge.repository.mongo; import com.ibrasoft.lensbridge.model.audit.AdminAction; import com.ibrasoft.lensbridge.model.audit.AuditEvent; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java index f84d911..999ba21 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.repository; +package com.ibrasoft.lensbridge.repository.mongo; import com.ibrasoft.lensbridge.model.event.Event; import org.springframework.data.mongodb.repository.MongoRepository; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java index 7af41c7..df0f3a7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.repository; +package com.ibrasoft.lensbridge.repository.mongo; import com.ibrasoft.lensbridge.model.auth.RefreshToken; import org.springframework.data.mongodb.repository.MongoRepository; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java index 8fb5f5b..2f32432 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.repository; +package com.ibrasoft.lensbridge.repository.mongo; import com.ibrasoft.lensbridge.model.upload.Upload; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java index c512136..e06506d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.repository; +package com.ibrasoft.lensbridge.repository.mongo; import java.util.Optional; import java.util.UUID; diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index 542e249..d0dcd26 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -89,6 +89,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/refresh-musallahboard").permitAll() .requestMatchers("/api/agent/enroll").permitAll() .requestMatchers("/api/agent/ws").permitAll() + .requestMatchers("/api/dashboard/ws/**").permitAll() .anyRequest().authenticated()); http.authenticationProvider(authenticationProvider()); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index 92ce60b..6541bf5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Service; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.repository.UserRepository; +import com.ibrasoft.lensbridge.repository.mongo.UserRepository; @Service public class UserDetailsServiceImpl implements UserDetailsService { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index 80b1491..7fb8a43 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -2,7 +2,7 @@ import com.ibrasoft.lensbridge.model.audit.AdminAction; import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import com.ibrasoft.lensbridge.repository.AuditEventRepository; +import com.ibrasoft.lensbridge.repository.mongo.AuditEventRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index 1bfb144..df9e3ee 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -2,7 +2,7 @@ import com.ibrasoft.lensbridge.model.event.Event; import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.repository.EventsRepository; +import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 7e236aa..4676476 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -4,8 +4,8 @@ import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.repository.EventsRepository; -import com.ibrasoft.lensbridge.repository.UploadRepository; +import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; +import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java index d228809..f5a753e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.model.auth.RefreshToken; -import com.ibrasoft.lensbridge.repository.RefreshTokenRepository; +import com.ibrasoft.lensbridge.repository.mongo.RefreshTokenRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java index b665cf1..e3b7737 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java @@ -2,7 +2,7 @@ import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.UploadRepository; +import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java index b4b5cb3..2316cd9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.repository.UploadRepository; +import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.coobird.thumbnailator.Thumbnails; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index cd4b873..65bd59a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -8,7 +8,7 @@ import com.ibrasoft.lensbridge.model.event.Event; import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.UploadRepository; +import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index e5d6756..a258c25 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -15,7 +15,7 @@ import com.ibrasoft.lensbridge.dto.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.repository.UserRepository; +import com.ibrasoft.lensbridge.repository.mongo.UserRepository; import com.ibrasoft.lensbridge.security.services.EmailService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java index 8627c17..2fb4bbc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java @@ -77,20 +77,26 @@ public OutgoingAgentFrame populateOutgoing(OutgoingAgentFrame.OutgoingAgentFrame return builder.seq(outgoingSeq.incrementAndGet()).sessionId(sessionId).build(); } - public void send(OutgoingAgentFrame frame) { + public boolean send(OutgoingAgentFrame frame) { try { String json = objectMapper.writeValueAsString(frame); synchronized (transport) { if (transport.isOpen()) { transport.sendMessage(new TextMessage(json)); + return true; } } + log.warn("session={} transport is closed; frame {} was not sent", sessionId, frame.getType()); + markClosed(); + return false; } catch (JsonProcessingException e) { log.error("session={} failed to serialize frame {}", sessionId, frame.getType(), e); close(CloseStatus.SERVER_ERROR); + return false; } catch (IOException e) { log.warn("session={} transport send failed: {}", sessionId, e.getMessage()); close(CloseStatus.SESSION_NOT_RELIABLE); + return false; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java index e1bc2be..e9067cb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistry.java @@ -39,6 +39,14 @@ public void unregister(UUID deviceId, AgentSession session) { byDevice.remove(deviceId, session); } + public void closeIfPresent(UUID deviceId, CloseStatus status) { + AgentSession session = byDevice.remove(deviceId); + if (session != null) { + log.info("Closing session {} for device {}", session.getSessionId(), deviceId); + session.close(status); + } + } + public int size() { return byDevice.size(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java new file mode 100644 index 0000000..e5b62a5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java @@ -0,0 +1,219 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.ibrasoft.lensbridge.dto.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.dto.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import com.ibrasoft.lensbridge.model.board.commands.CommandPayload; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * Bridges admin REST → agent WebSocket and the reverse path (ack/progress/result frames). + *

+ * Responsibilities: + *

    + *
  1. Issue: persist a {@link DeviceCommand}, push to a live session if one exists, + * otherwise leave PENDING for {@link #flushPending(UUID, AgentSession)}.
  2. + *
  3. Flush: on session auth, deliver all PENDING commands oldest-first.
  4. + *
  5. State machine: consume ack/progress/result frames and advance the + * {@link DeviceCommandStatus} state machine.
  6. + *
+ * Idempotency: the agent dedupes by {@code commandId}, so re-sending a command after a + * reconnect is safe — the agent re-emits a cached result. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class CommandDispatcher { + + private static final int DEFAULT_DEADLINE_MS = 30_000; + + /** Discriminator strings registered on {@link CommandPayload}. Updated when adding a command kind. */ + private static final Set KNOWN_KINDS = Set.of( + "chrome.reload", + "chrome.screenshot", + "kiosk.restart", + "system.reboot", + "config.refresh", + "logs.tail" + ); + + private final DeviceCommandRepository commandRepository; + private final DeviceRepository deviceRepository; + private final AgentSessionRegistry registry; + private final ObjectMapper objectMapper; + private final DeviceEventPublisher events; + + @Transactional + public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandRequest request) { + if (!KNOWN_KINDS.contains(request.kind())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown command kind: " + request.kind()); + } + Device device = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Device not found")); + if (device.getRevokedAt() != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Device is revoked"); + } + + JsonNode payload = request.payload() == null ? NullNode.getInstance() : request.payload(); + String payloadJson; + try { + payloadJson = objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid payload"); + } + + int deadline = request.deadlineMs() == null ? DEFAULT_DEADLINE_MS : request.deadlineMs(); + + DeviceCommand cmd = commandRepository.save(DeviceCommand.builder() + .deviceId(deviceId) + .kind(request.kind()) + .payloadJson(payloadJson) + .issuedBy(issuedBy) + .deadlineMs(deadline) + .status(DeviceCommandStatus.PENDING) + .build()); + + events.commandIssued(cmd); + + registry.get(deviceId).ifPresent(session -> deliverTo(cmd, session)); + + log.info("Issued command {} ({}) for device {} by {} — initial status {}", + cmd.getId(), cmd.getKind(), deviceId, issuedBy, cmd.getStatus()); + + return new CommandIssuedResponse( + cmd.getId(), cmd.getDeviceId(), cmd.getKind(), cmd.getStatus(), cmd.getIssuedAt()); + } + + /** Delivers all PENDING commands for the device to the freshly-authenticated session. */ + @Transactional + public void flushPending(UUID deviceId, AgentSession session) { + List pending = commandRepository + .findByDeviceIdAndStatusOrderByIssuedAtAsc(deviceId, DeviceCommandStatus.PENDING); + if (pending.isEmpty()) return; + log.info("Flushing {} pending command(s) to device {}", pending.size(), deviceId); + for (DeviceCommand cmd : pending) { + deliverTo(cmd, session); + } + } + + private void deliverTo(DeviceCommand cmd, AgentSession session) { + try { + JsonNode payload = cmd.getPayloadJson() == null + ? NullNode.getInstance() + : objectMapper.readTree(cmd.getPayloadJson()); + + OutgoingAgentFrame frame = session.populateOutgoing(OutgoingAgentFrame.builder() + .type("command") + .commandId(cmd.getId()) + .kind(cmd.getKind()) + .issuedBy(cmd.getIssuedBy()) + .deadlineMs(cmd.getDeadlineMs()) + .payload(payload)); + if (!session.send(frame)) { + log.warn("Leaving command {} PENDING because delivery to device {} did not complete", + cmd.getId(), cmd.getDeviceId()); + return; + } + + cmd.setStatus(DeviceCommandStatus.DELIVERED); + cmd.setDeliveredAt(Instant.now()); + commandRepository.save(cmd); + events.commandDelivered(cmd); + } catch (Exception e) { + log.error("Failed to deliver command {} to device {}: {}", + cmd.getId(), cmd.getDeviceId(), e.getMessage(), e); + } + } + + @Transactional + public void onAck(CommandAckFrame frame, AgentSession session) { + loadOwned(frame.getCommandId(), session.getDeviceId()).ifPresent(cmd -> { + if (cmd.getStatus().isTerminal()) return; + if (cmd.getAckedAt() != null) return; // idempotent re-ack + cmd.setStatus(DeviceCommandStatus.ACKED); + cmd.setAckedAt(Instant.now()); + commandRepository.save(cmd); + events.commandAcked(cmd); + }); + } + + @Transactional + public void onProgress(CommandProgressFrame frame, AgentSession session) { + loadOwned(frame.getCommandId(), session.getDeviceId()).ifPresent(cmd -> { + if (cmd.getStatus().isTerminal()) return; + if (cmd.getStatus() != DeviceCommandStatus.RUNNING) { + cmd.setStatus(DeviceCommandStatus.RUNNING); + if (cmd.getStartedAt() == null) cmd.setStartedAt(Instant.now()); + commandRepository.save(cmd); + } + events.commandProgress(cmd, frame); + }); + } + + @Transactional + public void onResult(CommandResultFrame frame, AgentSession session) { + loadOwned(frame.getCommandId(), session.getDeviceId()).ifPresent(cmd -> { + if (cmd.getStatus().isTerminal()) return; + + DeviceCommandStatus terminal = mapResultStatus(frame.getStatus()); + cmd.setStatus(terminal); + cmd.setFinishedAt(Instant.now()); + if (frame.getOutput() != null && !frame.getOutput().isNull()) { + cmd.setOutputJson(frame.getOutput().toString()); + } + if (frame.getErrorMessage() != null) { + cmd.setErrorMessage(frame.getErrorMessage()); + } + commandRepository.save(cmd); + events.commandResult(cmd, frame); + + log.info("Command {} for device {} terminated as {} (durationMs={})", + cmd.getId(), cmd.getDeviceId(), terminal, frame.getDurationMs()); + }); + } + + private Optional loadOwned(UUID commandId, UUID expectedDeviceId) { + if (commandId == null || expectedDeviceId == null) return Optional.empty(); + return commandRepository.findById(commandId).filter(cmd -> { + if (!expectedDeviceId.equals(cmd.getDeviceId())) { + log.warn("Frame references command {} owned by device {}, but session is for {}", + commandId, cmd.getDeviceId(), expectedDeviceId); + return false; + } + return true; + }); + } + + private static DeviceCommandStatus mapResultStatus(String status) { + if (status == null) return DeviceCommandStatus.FAILED; + return switch (status.toLowerCase()) { + case "ok", "success", "succeeded" -> DeviceCommandStatus.SUCCEEDED; + case "timeout" -> DeviceCommandStatus.TIMEOUT; + case "rejected" -> DeviceCommandStatus.REJECTED; + default -> DeviceCommandStatus.FAILED; + }; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java index 42ac2a0..2c8aa46 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java @@ -64,7 +64,6 @@ public Outcome enroll(String plaintextToken, EnrollmentToken token = consumed.get(); Device device = Device.builder() - .id(provisionalDeviceId) .displayName(hostname != null && !hostname.isBlank() ? token.getDisplayName() + " (" + hostname + ")" : token.getDisplayName()) @@ -77,6 +76,9 @@ public Outcome enroll(String plaintextToken, .build(); Device saved = deviceRepository.save(device); + + token.setConsumedByDeviceId(saved.getId()); + log.info("Enrolled device {} from token {} (issued by {})", saved.getId(), token.getId(), token.getCreatedBy()); return new Outcome.Ok(saved); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java index 0935721..6d37a76 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java @@ -5,6 +5,7 @@ import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceTelemetryRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +25,7 @@ public class HeartbeatService { private final DeviceRepository deviceRepository; private final DeviceTelemetryRepository telemetryRepository; + private final DeviceEventPublisher events; @Transactional public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { @@ -55,5 +57,7 @@ public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { .displayedFrameId(t.getDisplayedFrameId()) .build(); telemetryRepository.save(row); + + events.heartbeat(deviceId, frame); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java new file mode 100644 index 0000000..aa25e82 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java @@ -0,0 +1,116 @@ +package com.ibrasoft.lensbridge.service.agent.events; + +import com.ibrasoft.lensbridge.dto.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Thin wrapper over {@link SimpMessagingTemplate} that publishes structured events to + * the dashboard's STOMP topics. All payloads are plain {@link Map}s so the dashboard + * never needs to share schema with this module. + *

+ * If the STOMP broker is not enabled (e.g. in tests that don't load {@code StompConfig}), + * this component degrades to no-ops. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DeviceEventPublisher { + + private static final String TOPIC_DEVICES = "/topic/devices"; + + private final SimpMessagingTemplate messaging; + + public void deviceOnline(UUID deviceId) { + send(TOPIC_DEVICES, Map.of("event", "online", "deviceId", deviceId, "at", Instant.now())); + send(deviceTopic(deviceId), Map.of("event", "online", "deviceId", deviceId, "at", Instant.now())); + } + + public void deviceOffline(UUID deviceId) { + send(TOPIC_DEVICES, Map.of("event", "offline", "deviceId", deviceId, "at", Instant.now())); + send(deviceTopic(deviceId), Map.of("event", "offline", "deviceId", deviceId, "at", Instant.now())); + } + + public void heartbeat(UUID deviceId, HeartbeatFrame frame) { + Map body = new LinkedHashMap<>(); + body.put("event", "heartbeat"); + body.put("deviceId", deviceId); + body.put("at", Instant.now()); + body.put("telemetry", frame.getTelemetry()); + send(deviceTopic(deviceId), body); + } + + public void commandIssued(DeviceCommand cmd) { + publishCommandEvent(cmd, "issued", null); + } + + public void commandDelivered(DeviceCommand cmd) { + publishCommandEvent(cmd, "delivered", null); + } + + public void commandAcked(DeviceCommand cmd) { + publishCommandEvent(cmd, "acked", null); + } + + public void commandProgress(DeviceCommand cmd, CommandProgressFrame frame) { + Map extra = new LinkedHashMap<>(); + extra.put("stage", frame.getStage()); + extra.put("message", frame.getMessage()); + extra.put("percent", frame.getPercent()); + publishCommandEvent(cmd, "progress", extra); + } + + public void commandResult(DeviceCommand cmd, CommandResultFrame frame) { + Map extra = new LinkedHashMap<>(); + extra.put("status", frame.getStatus()); + extra.put("output", frame.getOutput()); + extra.put("durationMs", frame.getDurationMs()); + extra.put("errorMessage", frame.getErrorMessage()); + publishCommandEvent(cmd, "result", extra); + } + + private void publishCommandEvent(DeviceCommand cmd, String event, Map extra) { + Map body = new LinkedHashMap<>(); + body.put("event", event); + body.put("commandId", cmd.getId()); + body.put("deviceId", cmd.getDeviceId()); + body.put("kind", cmd.getKind()); + body.put("status", cmd.getStatus()); + body.put("issuedAt", cmd.getIssuedAt()); + body.put("at", Instant.now()); + if (extra != null) body.putAll(extra); + + send(commandTopic(cmd.getDeviceId(), cmd.getId()), body); + send(deviceCommandsTopic(cmd.getDeviceId()), body); + } + + private void send(String destination, Object payload) { + try { + messaging.convertAndSend(destination, payload); + } catch (Exception e) { + log.warn("STOMP publish to {} failed: {}", destination, e.getMessage()); + } + } + + private static String deviceTopic(UUID deviceId) { + return TOPIC_DEVICES + "/" + deviceId; + } + + private static String deviceCommandsTopic(UUID deviceId) { + return TOPIC_DEVICES + "/" + deviceId + "/commands"; + } + + private static String commandTopic(UUID deviceId, UUID commandId) { + return TOPIC_DEVICES + "/" + deviceId + "/commands/" + commandId; + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java new file mode 100644 index 0000000..f0c76df --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java @@ -0,0 +1,91 @@ +package com.ibrasoft.lensbridge.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.service.agent.AgentSession; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; +import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class DeviceAdminControllerTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void revoke_closesRegisteredAgentSession() throws Exception { + EnrollmentTokenService enrollmentTokenService = mock(EnrollmentTokenService.class); + DeviceRepository deviceRepository = mock(DeviceRepository.class); + DeviceCommandRepository commandRepository = mock(DeviceCommandRepository.class); + CommandDispatcher commandDispatcher = mock(CommandDispatcher.class); + AgentSessionRegistry registry = new AgentSessionRegistry(); + + DeviceAdminController controller = new DeviceAdminController( + enrollmentTokenService, + deviceRepository, + commandRepository, + commandDispatcher, + new ObjectMapper(), + registry); + + UUID deviceId = UUID.randomUUID(); + Device device = Device.builder() + .id(deviceId) + .displayName("Lobby board") + .audience(Audience.BOTH) + .build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + when(deviceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + WebSocketSession transport = mock(WebSocketSession.class); + when(transport.isOpen()).thenReturn(true); + AgentSession session = new AgentSession(transport, "challenge-base64", new ObjectMapper()); + session.markAuthenticated(deviceId); + registry.register(deviceId, session); + + setCurrentUser(); + + controller.revoke(deviceId); + + assertNotNull(device.getRevokedAt()); + assertTrue(registry.get(deviceId).isEmpty()); + verify(transport).close(argThat(status -> + status.getCode() == CloseStatus.POLICY_VIOLATION.getCode() + && "device_revoked".equals(status.getReason()))); + } + + private static void setCurrentUser() { + UserDetailsImpl user = new UserDetailsImpl( + UUID.randomUUID(), + "Root", + "Admin", + "root@example.com", + "1000000000", + "password", + true, + List.of(new SimpleGrantedAuthority("ROLE_ROOT"))); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java index b305d3d..aedd631 100644 --- a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java @@ -6,8 +6,10 @@ import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; import com.ibrasoft.lensbridge.service.agent.Ed25519TestUtil; import com.ibrasoft.lensbridge.service.agent.HeartbeatService; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; import com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload; import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; import org.junit.jupiter.api.BeforeEach; @@ -36,6 +38,8 @@ class AgentWebSocketHandlerTest { private DeviceRepository deviceRepo; private AgentSessionRegistry registry; private HeartbeatService heartbeatService; + private CommandDispatcher commandDispatcher; + private DeviceEventPublisher events; private AgentWebSocketHandler handler; @BeforeEach @@ -44,7 +48,11 @@ void setUp() { deviceRepo = mock(DeviceRepository.class); registry = new AgentSessionRegistry(); heartbeatService = mock(HeartbeatService.class); - handler = new AgentWebSocketHandler(mapper, new Ed25519Verifier(), deviceRepo, registry, heartbeatService); + commandDispatcher = mock(CommandDispatcher.class); + events = mock(DeviceEventPublisher.class); + handler = new AgentWebSocketHandler( + mapper, new Ed25519Verifier(), deviceRepo, registry, + heartbeatService, commandDispatcher, events); } @Test diff --git a/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java b/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java index 31beec4..1e5ae18 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java @@ -2,7 +2,7 @@ import com.ibrasoft.lensbridge.model.event.Event; import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.repository.EventsRepository; +import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java new file mode 100644 index 0000000..2f79879 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java @@ -0,0 +1,233 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ibrasoft.lensbridge.dto.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.socket.WebSocketSession; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class CommandDispatcherTest { + + private DeviceCommandRepository commandRepo; + private DeviceRepository deviceRepo; + private AgentSessionRegistry registry; + private ObjectMapper mapper; + private DeviceEventPublisher events; + private CommandDispatcher dispatcher; + + @BeforeEach + void setUp() { + commandRepo = mock(DeviceCommandRepository.class); + deviceRepo = mock(DeviceRepository.class); + registry = new AgentSessionRegistry(); + mapper = new ObjectMapper(); + events = mock(DeviceEventPublisher.class); + dispatcher = new CommandDispatcher(commandRepo, deviceRepo, registry, mapper, events); + + when(commandRepo.save(any())).thenAnswer(inv -> { + DeviceCommand c = inv.getArgument(0); + if (c.getId() == null) c.setId(UUID.randomUUID()); + if (c.getIssuedAt() == null) c.setIssuedAt(Instant.now()); + return c; + }); + } + + @Test + void issue_persistsAsPending_whenDeviceOffline() { + UUID deviceId = registerDevice(); + + CommandIssuedResponse resp = dispatcher.issue(deviceId, "admin@example.com", + new IssueCommandRequest("chrome.reload", null, null)); + + assertEquals(deviceId, resp.deviceId()); + assertEquals(DeviceCommandStatus.PENDING, resp.status()); + verify(events).commandIssued(any()); + verify(events, never()).commandDelivered(any()); + } + + @Test + void issue_pushesToLiveSession_andMarksDelivered() throws Exception { + UUID deviceId = registerDevice(); + AgentSession session = liveSession(deviceId); + + dispatcher.issue(deviceId, "admin", new IssueCommandRequest("chrome.reload", null, 15_000)); + + verify(session.getTransport(), atLeastOnce()).sendMessage(any()); + verify(events).commandDelivered(any()); + } + + @Test + void issue_leavesPending_whenRegisteredSessionIsClosed() { + UUID deviceId = registerDevice(); + AgentSession session = closedSession(deviceId); + + CommandIssuedResponse resp = dispatcher.issue(deviceId, "admin", + new IssueCommandRequest("chrome.reload", null, 15_000)); + + assertEquals(DeviceCommandStatus.PENDING, resp.status()); + verify(events, never()).commandDelivered(any()); + } + + @Test + void issue_rejectsUnknownKind() { + UUID deviceId = registerDevice(); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> + dispatcher.issue(deviceId, "admin", new IssueCommandRequest("not.a.thing", null, null))); + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void issue_rejectsRevokedDevice() { + UUID deviceId = UUID.randomUUID(); + Device d = device(deviceId); + d.setRevokedAt(Instant.now().minusSeconds(60)); + when(deviceRepo.findById(deviceId)).thenReturn(Optional.of(d)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> + dispatcher.issue(deviceId, "admin", new IssueCommandRequest("chrome.reload", null, null))); + assertEquals(409, ex.getStatusCode().value()); + } + + @Test + void onResult_movesToTerminalState() { + UUID deviceId = registerDevice(); + AgentSession session = liveSession(deviceId); + DeviceCommand cmd = persistedCommand(deviceId, DeviceCommandStatus.RUNNING); + + CommandResultFrame frame = new CommandResultFrame(); + frame.setCommandId(cmd.getId()); + frame.setStatus("ok"); + ObjectNode out = JsonNodeFactory.instance.objectNode(); + out.put("ok", true); + frame.setOutput(out); + frame.setDurationMs(123L); + + dispatcher.onResult(frame, session); + + assertEquals(DeviceCommandStatus.SUCCEEDED, cmd.getStatus()); + assertNotNull(cmd.getFinishedAt()); + verify(events).commandResult(eq(cmd), eq(frame)); + } + + @Test + void onResult_isIdempotentForTerminalStates() { + UUID deviceId = registerDevice(); + AgentSession session = liveSession(deviceId); + DeviceCommand cmd = persistedCommand(deviceId, DeviceCommandStatus.SUCCEEDED); + cmd.setFinishedAt(Instant.now()); + + CommandResultFrame frame = new CommandResultFrame(); + frame.setCommandId(cmd.getId()); + frame.setStatus("error"); + dispatcher.onResult(frame, session); + + assertEquals(DeviceCommandStatus.SUCCEEDED, cmd.getStatus()); + verify(events, never()).commandResult(any(), any()); + } + + @Test + void onAck_ignoresMismatchedDevice() { + UUID deviceA = registerDevice(); + UUID deviceB = UUID.randomUUID(); + AgentSession sessionB = liveSession(deviceB); + DeviceCommand cmdForA = persistedCommand(deviceA, DeviceCommandStatus.DELIVERED); + + CommandAckFrame ack = new CommandAckFrame(); + ack.setCommandId(cmdForA.getId()); + dispatcher.onAck(ack, sessionB); + + assertEquals(DeviceCommandStatus.DELIVERED, cmdForA.getStatus()); + verify(events, never()).commandAcked(any()); + } + + @Test + void flushPending_deliversAllPendingCommands() { + UUID deviceId = registerDevice(); + AgentSession session = liveSession(deviceId); + + DeviceCommand c1 = persistedCommand(deviceId, DeviceCommandStatus.PENDING); + DeviceCommand c2 = persistedCommand(deviceId, DeviceCommandStatus.PENDING); + when(commandRepo.findByDeviceIdAndStatusOrderByIssuedAtAsc(deviceId, DeviceCommandStatus.PENDING)) + .thenReturn(List.of(c1, c2)); + + dispatcher.flushPending(deviceId, session); + + assertEquals(DeviceCommandStatus.DELIVERED, c1.getStatus()); + assertEquals(DeviceCommandStatus.DELIVERED, c2.getStatus()); + verify(events, times(2)).commandDelivered(any()); + } + + // ── helpers ─────────────────────────────────────────────────────────── + + private UUID registerDevice() { + UUID id = UUID.randomUUID(); + when(deviceRepo.findById(id)).thenReturn(Optional.of(device(id))); + return id; + } + + private Device device(UUID id) { + return Device.builder() + .id(id) + .displayName("test") + .audience(Audience.BOTH) + .publicKey(new byte[32]) + .build(); + } + + private DeviceCommand persistedCommand(UUID deviceId, DeviceCommandStatus status) { + DeviceCommand cmd = DeviceCommand.builder() + .id(UUID.randomUUID()) + .deviceId(deviceId) + .kind("chrome.reload") + .payloadJson("{}") + .issuedBy("admin") + .deadlineMs(30_000) + .status(status) + .issuedAt(Instant.now()) + .build(); + when(commandRepo.findById(cmd.getId())).thenReturn(Optional.of(cmd)); + return cmd; + } + + private AgentSession liveSession(UUID deviceId) { + WebSocketSession transport = mock(WebSocketSession.class); + when(transport.isOpen()).thenReturn(true); + AgentSession session = new AgentSession(transport, "challenge-base64", mapper); + session.markAuthenticated(deviceId); + registry.register(deviceId, session); + return session; + } + + private AgentSession closedSession(UUID deviceId) { + WebSocketSession transport = mock(WebSocketSession.class); + when(transport.isOpen()).thenReturn(false); + AgentSession session = new AgentSession(transport, "challenge-base64", mapper); + session.markAuthenticated(deviceId); + registry.register(deviceId, session); + return session; + } + +} From 645809198d3a0966934017da73aa42593bd9a2ad Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 13:38:15 -0400 Subject: [PATCH 07/52] updated and normalized entities --- .../controller/AdminController.java | 32 +++---- .../controller/BoardAdminController.java | 16 ++-- .../{AdminAction.java => AuditAction.java} | 4 +- .../model/audit/AuditEntityType.java | 20 ++++ .../lensbridge/model/audit/AuditEvent.java | 47 ++++++++-- .../lensbridge/model/auth/RefreshToken.java | 45 +++++---- .../ibrasoft/lensbridge/model/auth/User.java | 93 +++++-------------- .../model/auth/VerificationToken.java | 37 ++++++++ .../lensbridge/model/event/Event.java | 18 +++- .../lensbridge/model/upload/Upload.java | 23 ++++- 10 files changed, 202 insertions(+), 133 deletions(-) rename src/main/java/com/ibrasoft/lensbridge/model/audit/{AdminAction.java => AuditAction.java} (96%) create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index d68ae5e..55c2d4a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.model.audit.AuditEvent; import com.ibrasoft.lensbridge.dto.request.SignupRequest; @@ -45,14 +45,14 @@ public class AdminController { private final AdminAuditService auditService; private final UserService userService; - private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AdminAction auditAction, String successMessage) { + private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(uploadId); UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); this.auditService.logAuditEvent(curr.getEmail(), auditAction, "Upload", uploadId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } - private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AdminAction auditAction, String successMessage) { + private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(userId); UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); this.auditService.logAuditEvent(curr.getEmail(), auditAction, "User", userId, request.getRemoteAddr()); @@ -60,7 +60,7 @@ private ResponseEntity executeUserAction(UUID userId, HttpServletRequest requ } private ResponseEntity executeCreationAction(T input, HttpServletRequest request, - Function serviceAction, AdminAction auditAction, + Function serviceAction, AuditAction auditAction, String entityType, Function idExtractor, String successMessage) { R createdEntity = serviceAction.apply(input); @@ -79,7 +79,7 @@ public ResponseEntity createEvent(@RequestParam("eventName") String eventName return executeCreationAction(null, request, (ignored) -> eventsService.createEvent(eventName, eventDate), - AdminAction.CREATE_EVENT, + AuditAction.CREATE_EVENT, "Event", Event::getId, "Event created successfully"); @@ -88,27 +88,27 @@ public ResponseEntity createEvent(@RequestParam("eventName") String eventName // Upload Management Operations @PostMapping("/upload/{uploadId}") public ResponseEntity approveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::approveUpload, AdminAction.APPROVE_UPLOAD, "Upload approved successfully"); + return executeUploadAction(uploadId, request, uploadService::approveUpload, AuditAction.APPROVE_UPLOAD, "Upload approved successfully"); } @DeleteMapping("/upload/{uploadId}") public ResponseEntity deleteUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::deleteUpload, AdminAction.DELETE_UPLOAD, "Upload deleted successfully"); + return executeUploadAction(uploadId, request, uploadService::deleteUpload, AuditAction.DELETE_UPLOAD, "Upload deleted successfully"); } @PostMapping("/feature-upload/{uploadId}") public ResponseEntity featureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::featureUpload, AdminAction.FEATURE_UPLOAD, "Upload featured successfully"); + return executeUploadAction(uploadId, request, uploadService::featureUpload, AuditAction.FEATURE_UPLOAD, "Upload featured successfully"); } @DeleteMapping("/upload/{uploadId}/approval") public ResponseEntity unapproveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::unapproveUpload, AdminAction.UNAPPROVE_UPLOAD, "Upload unapproved successfully"); + return executeUploadAction(uploadId, request, uploadService::unapproveUpload, AuditAction.UNAPPROVE_UPLOAD, "Upload unapproved successfully"); } @DeleteMapping("/upload/{uploadId}/featured") public ResponseEntity unfeatureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { - return executeUploadAction(uploadId, request, uploadService::unfeatureUpload, AdminAction.UNFEATURE_UPLOAD, "Upload unfeatured successfully"); + return executeUploadAction(uploadId, request, uploadService::unfeatureUpload, AuditAction.UNFEATURE_UPLOAD, "Upload unfeatured successfully"); } // Data Retrieval Operations @@ -181,7 +181,7 @@ public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody R log.debug("Admin adding role {} to user: {}", role, id); userService.addRole(id, role); }, - AdminAction.ADD_USER_ROLE, + AuditAction.ADD_USER_ROLE, "Role added successfully to user: " + userId); } @@ -193,7 +193,7 @@ public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestB log.debug("Admin removing role {} from user: {}", role, id); userService.removeRole(id, role); }, - AdminAction.REMOVE_USER_ROLE, + AuditAction.REMOVE_USER_ROLE, "Role removed successfully from user: " + userId); } @@ -201,7 +201,7 @@ public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestB @PreAuthorize("hasRole('" + Role.ROOT + "')") public ResponseEntity verifyUser(@RequestBody Map payload, HttpServletRequest request) { return executeUserAction(payload.get("userId"), request, - userService::verifyDirectly, AdminAction.VERIFY_USER, + userService::verifyDirectly, AuditAction.VERIFY_USER, "User verified successfully"); } @@ -216,7 +216,7 @@ public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, Ht userService.requestPasswordReset(newUser.getEmail()); return newUser; }, - AdminAction.ADD_USER, + AuditAction.ADD_USER, "User", User::getId, "User created successfully: " + signUpRequest.getEmail()); @@ -271,7 +271,7 @@ public ResponseEntity getUploadAuditHistory(@PathVariable UUID uploadId) { } @GetMapping("/audit/action/{action}") - public ResponseEntity getAuditEventsByAction(@PathVariable AdminAction action, Pageable pageable) { + public ResponseEntity getAuditEventsByAction(@PathVariable AuditAction action, Pageable pageable) { try { log.debug("Admin retrieving audit events for action: {}", action); Page events = auditService.getAuditEventsByAction(action, pageable); @@ -298,7 +298,7 @@ public ResponseEntity getAuditEventsByDateRange(@RequestParam Instant start, public ResponseEntity getAvailableActions() { try { log.debug("Admin retrieving available audit actions"); - return ResponseEntity.ok(AdminAction.values()); + return ResponseEntity.ok(AuditAction.values()); } catch (Exception e) { log.error("Error retrieving available actions: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve available actions: " + e.getMessage())); diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index b785afe..420053d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -8,7 +8,7 @@ import com.ibrasoft.lensbridge.dto.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.response.MessageResponse; import com.ibrasoft.lensbridge.handler.SignboardHandler; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Event; @@ -180,7 +180,7 @@ public ResponseEntity createPoster( Poster response = posterService.createPoster(createRequest, imageFile); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -195,7 +195,7 @@ public ResponseEntity updatePoster( Poster response = posterService.updatePoster(posterId, updateRequest); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } @@ -210,7 +210,7 @@ public ResponseEntity updatePosterImage( Poster response = posterService.updatePosterImage(posterId, imageFile); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } @@ -224,7 +224,7 @@ public ResponseEntity deletePoster( posterService.deletePoster(posterId); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Poster deleted successfully")); } @@ -258,7 +258,7 @@ public ResponseEntity createEvent( Event created = boardService.createEvent(createRequest); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @@ -273,7 +273,7 @@ public ResponseEntity updateEvent( Event updated = boardService.updateEvent(eventId, updateRequest); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(updated); } @@ -287,7 +287,7 @@ public ResponseEntity deleteEvent( boardService.deleteEvent(eventId); UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AdminAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); + auditService.logAuditEvent(user.getEmail(), AuditAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Calendar event deleted successfully")); } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AdminAction.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java similarity index 96% rename from src/main/java/com/ibrasoft/lensbridge/model/audit/AdminAction.java rename to src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java index deb0025..7f86d3e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AdminAction.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public enum AdminAction { +public enum AuditAction { // Upload Management Actions APPROVE_UPLOAD("Approve Upload"), UNAPPROVE_UPLOAD("Remove Upload Approval"), @@ -52,7 +52,7 @@ public enum AdminAction { private final String description; - AdminAction(String description) { + AuditAction(String description) { this.description = description; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java new file mode 100644 index 0000000..a026384 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java @@ -0,0 +1,20 @@ +package com.ibrasoft.lensbridge.model.audit; + +public enum AuditEntityType { + USER("User"), + UPLOAD("Upload"), + MUSALLAH_BOARD("Musallah Board"), + EVENT("Event"); + + + private final String description; + + AuditEntityType(String description) { + this.description = description; + } + + @Override + public String toString() { + return this.name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java index 7ac94d4..5e9c189 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java @@ -2,27 +2,54 @@ import lombok.Builder; import lombok.Data; + +import org.hibernate.annotations.Collate; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; +import com.ibrasoft.lensbridge.model.auth.User; + +import jakarta.annotation.Generated; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + import java.time.Instant; import java.util.UUID; @Data @Builder -@Document(collection = "audit_events") +@Entity +@Table(name = "audit_events") public class AuditEvent { @Id - private String id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AuditAction action; + + @Column(nullable = false) private Instant timestamp; - private String adminEmail; - private UUID adminId; - private AdminAction action; - private String entityType; - private UUID entityId; - private String details; - private String result; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private User admin; + + + @Enumerated(EnumType.STRING) + private AuditEntityType targetEntityType; + + private UUID targetEntityId; + private String ipAddress; private String userAgent; - private String sessionId; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java index f1d91ef..29f5ad1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java @@ -1,17 +1,25 @@ package com.ibrasoft.lensbridge.model.auth; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.index.Indexed; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.Builder; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; -@Document(collection = "refresh_tokens") +@Entity +@Table(name = "refresh_tokens") @Data @NoArgsConstructor @AllArgsConstructor @@ -19,27 +27,26 @@ public class RefreshToken { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Indexed(unique = true) + @NotBlank + @Column(nullable = false, unique = true) private String token; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; - @Indexed - private UUID userId; - - private LocalDateTime expiryDate; - - private LocalDateTime createdDate; - - private LocalDateTime lastUsedDate; + @Column(nullable = false) + private Instant expiryDate; - private String deviceInfo; + @Column(nullable = false) + private Instant createdDate; - private String ipAddress; + @Column(nullable = false) + private Instant lastUsedDate; + @Column(nullable = false) private boolean revoked; - - public boolean isExpired() { - return LocalDateTime.now().isAfter(this.expiryDate); - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index 359558e..cba463c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -1,15 +1,23 @@ package com.ibrasoft.lensbridge.model.auth; -import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.Indexed; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -import org.springframework.data.mongodb.core.mapping.Document; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -17,10 +25,12 @@ import lombok.Data; @AllArgsConstructor -@Document(collection = "users") @Data +@Entity +@Table(name = "users") public class User { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @NotBlank @@ -30,84 +40,27 @@ public class User { private String lastName; @NotBlank - @Indexed(unique = true) @Size(max = 10) private String studentNumber; @NotBlank - @Size(max = 50) + @Size(max = 254) @Email - @Indexed(unique = true) private String email; @NotBlank - @Size(max = 120) @JsonIgnore - private String password; + private String passwordHash; - private List roles; + @ElementCollection(targetClass = Role.class) + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_roles") + private Set roles; + private boolean verified; - private String verificationToken; - - public User() { - this.id = UUID.randomUUID(); - } - - public User(String firstName, String lastName, String studentNumber, String email, String password){ - this( - UUID.randomUUID(), - firstName, - lastName, - studentNumber, - email, - password, - List.of(), - false, - null - ); - } - - public void addRole(String role) { - if (this.roles == null) { - this.roles = new ArrayList<>(); - } - this.roles.add(role); - } - - /** - * Adds a role using the Role enum for type safety. - * @param role the role to add - */ - public void addRole(Role role) { - addRole(role.getAuthority()); - } - - /** - * Checks if the user has a specific role using the Role enum. - * @param role the role to check for - * @return true if the user has the role, false otherwise - */ - public boolean hasRole(Role role) { - return this.roles != null && this.roles.contains(role.getAuthority()); - } - /** - * Checks if the user has a specific role using a string. - * @param role the role string to check for - * @return true if the user has the role, false otherwise - */ - public boolean hasRole(String role) { - return this.roles != null && this.roles.contains(role); - } + @ManyToOne + private User user; - /** - * Removes a role using the Role enum. - * @param role the role to remove - */ - public void removeRole(Role role) { - if (this.roles != null) { - this.roles.remove(role.getAuthority()); - } - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java new file mode 100644 index 0000000..f5ea0e3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java @@ -0,0 +1,37 @@ +package com.ibrasoft.lensbridge.model.auth; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; + +@Entity +@Builder +@AllArgsConstructor +@Table(name = "verification_tokens") +public class VerificationToken { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String tokenHash; + + @OneToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private Instant createdAt; + private Instant expiresAt; + + private boolean used; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java index 29808f0..b99713b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java @@ -5,20 +5,30 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import java.time.LocalDateTime; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Table; + +import java.time.Instant; import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @Data @Builder -@Document(collection = "events") +@Entity +@Table(name = "events") public class Event { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID id; private String name; - private LocalDateTime date; + private Instant date; + + @Enumerated(EnumType.STRING) private EventStatus status; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index b4c4806..6fe409f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -4,32 +4,47 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import java.time.LocalDateTime; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Table; + +import java.time.Instant; import java.util.UUID; +@Entity +@Table(name = "devices") @Data @NoArgsConstructor @AllArgsConstructor -@Document(collection = "uploads") +/** + * Represents an uploaded media file, including its metadata and status. + * This class is akin to the inode models in a filesystem, storing metadata and the location of the uploaded content (R2) + */ public class Upload { @Id + @GeneratedValue(strategy = GenerationType.UUID) private UUID uuid; private String fileName; private String fileUrl; private String thumbnailUrl; + private String uploadDescription; private String instagramHandle; private UUID uploadedBy; private UUID eventId; - private LocalDateTime createdDate; + private Instant createdDate; private boolean approved; private boolean featured; private boolean isAnon; + @Enumerated(EnumType.STRING) private UploadType contentType; } \ No newline at end of file From 90382a449c95a517e51144504daadd09aeb6bb36 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 13:49:53 -0400 Subject: [PATCH 08/52] started moving repos around --- .../lensbridge/model/auth/RefreshToken.java | 2 +- .../com/ibrasoft/lensbridge/model/auth/User.java | 13 +++++++------ .../{mongo => auth}/RefreshTokenRepository.java | 7 ++++--- .../repository/{mongo => auth}/UserRepository.java | 6 +++--- .../auth/VerificationTokenRepository.java | 14 ++++++++++++++ .../repository/mongo/AuditEventRepository.java | 6 +++--- .../security/services/UserDetailsServiceImpl.java | 2 +- 7 files changed, 33 insertions(+), 17 deletions(-) rename src/main/java/com/ibrasoft/lensbridge/repository/{mongo => auth}/RefreshTokenRepository.java (78%) rename src/main/java/com/ibrasoft/lensbridge/repository/{mongo => auth}/UserRepository.java (66%) create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java index 29f5ad1..5887fe0 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java @@ -32,7 +32,7 @@ public class RefreshToken { @NotBlank @Column(nullable = false, unique = true) - private String token; + private String tokenHash; @ManyToOne @JoinColumn(name = "user_id", nullable = false) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index cba463c..e141ff0 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -7,12 +7,14 @@ import org.springframework.data.annotation.Id; import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; +import jakarta.persistence.Index; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.validation.constraints.Email; @@ -27,7 +29,9 @@ @AllArgsConstructor @Data @Entity -@Table(name = "users") +@Table(name = "users", indexes = { + @Index(name = "idx_users_email", columnList = "email") +}) public class User { @Id @GeneratedValue(strategy = GenerationType.UUID) @@ -41,11 +45,13 @@ public class User { @NotBlank @Size(max = 10) + @Column(unique = true, nullable = false) private String studentNumber; @NotBlank @Size(max = 254) @Email + @Column(nullable = false, unique = true) private String email; @NotBlank @@ -56,11 +62,6 @@ public class User { @Enumerated(EnumType.STRING) @CollectionTable(name = "user_roles") private Set roles; - private boolean verified; - - @ManyToOne - private User user; - } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java similarity index 78% rename from src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java index df0f3a7..21d4dbc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/RefreshTokenRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java @@ -1,7 +1,8 @@ -package com.ibrasoft.lensbridge.repository.mongo; +package com.ibrasoft.lensbridge.repository.auth; import com.ibrasoft.lensbridge.model.auth.RefreshToken; -import org.springframework.data.mongodb.repository.MongoRepository; + +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -10,7 +11,7 @@ import java.util.UUID; @Repository -public interface RefreshTokenRepository extends MongoRepository { +public interface RefreshTokenRepository extends JpaRepository { Optional findByToken(String token); diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java similarity index 66% rename from src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java index e06506d..344a6b8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UserRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java @@ -1,15 +1,15 @@ -package com.ibrasoft.lensbridge.repository.mongo; +package com.ibrasoft.lensbridge.repository.auth; import java.util.Optional; import java.util.UUID; -import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import com.ibrasoft.lensbridge.model.auth.User; @Repository -public interface UserRepository extends MongoRepository { +public interface UserRepository extends JpaRepository { Optional findByEmail(String email); diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java new file mode 100644 index 0000000..4ca29d4 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java @@ -0,0 +1,14 @@ +package com.ibrasoft.lensbridge.repository.auth; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; + +@Repository +public interface VerificationTokenRepository extends JpaRepository { + User findUserByToken(String tokenHash); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java index ac1fe38..02e4640 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.repository.mongo; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.audit.AuditEvent; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,7 +19,7 @@ public interface AuditEventRepository extends MongoRepository findByEntityTypeAndEntityIdOrderByTimestampDesc(String entityType, UUID entityId); - Page findByActionOrderByTimestampDesc(AdminAction action, Pageable pageable); + Page findByActionOrderByTimestampDesc(AuditAction action, Pageable pageable); @Query("{'timestamp': {'$gte': ?0, '$lte': ?1}}") Page findByTimestampBetweenOrderByTimestampDesc(Instant start, Instant end, Pageable pageable); @@ -31,5 +31,5 @@ public interface AuditEventRepository extends MongoRepository Date: Sun, 10 May 2026 13:52:04 -0400 Subject: [PATCH 09/52] This is fun why did I start vibe codign? --- .../lensbridge/controller/FileUploadController.java | 2 +- .../ibrasoft/lensbridge/dto/response/AdminUploadDto.java | 3 ++- .../lensbridge/model/{upload => media}/Upload.java | 2 +- .../lensbridge/model/{upload => media}/UploadType.java | 2 +- .../repository/{mongo => media}/UploadRepository.java | 5 +++-- .../ibrasoft/lensbridge/service/AdminAuditService.java | 8 ++++---- .../ibrasoft/lensbridge/service/DirectUploadService.java | 3 ++- .../com/ibrasoft/lensbridge/service/GalleryService.java | 5 +++-- .../ibrasoft/lensbridge/service/RefreshTokenService.java | 3 ++- .../lensbridge/service/ThumbnailBackfillService.java | 7 ++++--- .../com/ibrasoft/lensbridge/service/ThumbnailService.java | 5 +++-- .../com/ibrasoft/lensbridge/service/UploadService.java | 7 ++++--- .../java/com/ibrasoft/lensbridge/service/UserService.java | 2 +- 13 files changed, 31 insertions(+), 23 deletions(-) rename src/main/java/com/ibrasoft/lensbridge/model/{upload => media}/Upload.java (96%) rename src/main/java/com/ibrasoft/lensbridge/model/{upload => media}/UploadType.java (88%) rename src/main/java/com/ibrasoft/lensbridge/repository/{mongo => media}/UploadRepository.java (93%) diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index 35d1662..724de02 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -4,7 +4,7 @@ import com.ibrasoft.lensbridge.dto.response.*; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.auth.Role; -import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.model.media.Upload; import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.DirectUploadService; import com.ibrasoft.lensbridge.service.UploadService; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java index 16910ac..f50ccb3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java @@ -1,6 +1,5 @@ package com.ibrasoft.lensbridge.dto.response; -import com.ibrasoft.lensbridge.model.upload.UploadType; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,6 +7,8 @@ import java.time.LocalDateTime; import java.util.UUID; +import com.ibrasoft.lensbridge.model.media.UploadType; + @Data @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/media/Upload.java similarity index 96% rename from src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java rename to src/main/java/com/ibrasoft/lensbridge/model/media/Upload.java index 6fe409f..9195fd9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/media/Upload.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.model.upload; +package com.ibrasoft.lensbridge.model.media; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/UploadType.java b/src/main/java/com/ibrasoft/lensbridge/model/media/UploadType.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/model/upload/UploadType.java rename to src/main/java/com/ibrasoft/lensbridge/model/media/UploadType.java index b7d385f..850e1e5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/UploadType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/media/UploadType.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.model.upload; +package com.ibrasoft.lensbridge.model.media; public enum UploadType { IMAGE("image"), diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java similarity index 93% rename from src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java rename to src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java index 2f32432..7de47ed 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/UploadRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java @@ -1,11 +1,12 @@ -package com.ibrasoft.lensbridge.repository.mongo; +package com.ibrasoft.lensbridge.repository.media; -import com.ibrasoft.lensbridge.model.upload.Upload; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; +import com.ibrasoft.lensbridge.model.media.Upload; + import java.time.LocalDateTime; import java.util.List; import java.util.Optional; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index 7fb8a43..7aeaa24 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.audit.AuditEvent; import com.ibrasoft.lensbridge.repository.mongo.AuditEventRepository; import lombok.RequiredArgsConstructor; @@ -32,7 +32,7 @@ public AuditEvent logAuditEvent(AuditEvent event) { } } - public AuditEvent logAuditEvent(String adminEmail, AdminAction action, String entityType, UUID entityId, String IPAddress) { + public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, String IPAddress) { AuditEvent event = AuditEvent.builder().adminEmail(adminEmail).action(action).entityType(entityType).entityId(entityId).timestamp(Instant.now()).ipAddress(IPAddress).build(); return logAuditEvent(event); } @@ -49,7 +49,7 @@ public List getAuditEventsByEntity(String entityType, UUID entityId) return auditEventRepository.findByEntityTypeAndEntityIdOrderByTimestampDesc(entityType, entityId); } - public Page getAuditEventsByAction(AdminAction action, Pageable pageable) { + public Page getAuditEventsByAction(AuditAction action, Pageable pageable) { return auditEventRepository.findByActionOrderByTimestampDesc(action, pageable); } @@ -66,7 +66,7 @@ public long getOperationCountByAdmin(UUID adminId) { return auditEventRepository.countByAdminId(adminId); } - public long getOperationCountByAction(AdminAction action) { + public long getOperationCountByAction(AuditAction action) { return auditEventRepository.countByAction(action); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java index 814b47b..9744d73 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java @@ -7,7 +7,8 @@ import com.ibrasoft.lensbridge.dto.response.PresignedUploadResponse; import com.ibrasoft.lensbridge.dto.response.UploadCompletionResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.model.media.Upload; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 4676476..24ea55d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -2,10 +2,11 @@ import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.model.event.Event; +import com.ibrasoft.lensbridge.model.media.Upload; +import com.ibrasoft.lensbridge.repository.media.UploadRepository; import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; -import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java index f5a753e..275fff8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java @@ -1,7 +1,8 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.model.auth.RefreshToken; -import com.ibrasoft.lensbridge.repository.mongo.RefreshTokenRepository; +import com.ibrasoft.lensbridge.repository.auth.RefreshTokenRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java index e3b7737..d36d12c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java @@ -1,8 +1,9 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; +import com.ibrasoft.lensbridge.model.media.Upload; +import com.ibrasoft.lensbridge.model.media.UploadType; +import com.ibrasoft.lensbridge.repository.media.UploadRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java index 2316cd9..dcff79d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java @@ -1,7 +1,8 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; +import com.ibrasoft.lensbridge.model.media.Upload; +import com.ibrasoft.lensbridge.repository.media.UploadRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.coobird.thumbnailator.Thumbnails; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index 65bd59a..d3c9a30 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -6,9 +6,10 @@ import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.mongo.UploadRepository; +import com.ibrasoft.lensbridge.model.media.Upload; +import com.ibrasoft.lensbridge.model.media.UploadType; +import com.ibrasoft.lensbridge.repository.media.UploadRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index a258c25..f7a9cbf 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -15,7 +15,7 @@ import com.ibrasoft.lensbridge.dto.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.repository.mongo.UserRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; import com.ibrasoft.lensbridge.security.services.EmailService; import lombok.RequiredArgsConstructor; From f5b611efb3e46ca05bcfec47cdc87f0df5595d2a Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 15:08:23 -0400 Subject: [PATCH 10/52] Finalized all new models and repositories. Believe it or not, none of this is vibe coded :) --- .../controller/AdminController.java | 4 +- .../controller/EventsController.java | 4 +- .../controller/FileUploadController.java | 2 +- .../dto/response/AdminUploadDto.java | 2 +- .../model/{event => upload}/Event.java | 2 +- .../model/{event => upload}/EventStatus.java | 2 +- .../model/{media => upload}/Upload.java | 36 +++++++++++++--- .../model/{media => upload}/UploadType.java | 2 +- .../audit/AuditEventRepository.java | 12 ++++++ .../repository/media/UploadRepository.java | 41 ------------------- .../mongo/AuditEventRepository.java | 35 ---------------- .../repository/mongo/EventsRepository.java | 13 ------ .../repository/upload/EventsRepository.java | 14 +++++++ .../repository/upload/UploadRepository.java | 19 +++++++++ .../lensbridge/service/AdminAuditService.java | 3 +- .../service/DirectUploadService.java | 2 +- .../lensbridge/service/EventsService.java | 7 ++-- .../lensbridge/service/GalleryService.java | 8 ++-- .../service/ThumbnailBackfillService.java | 6 +-- .../lensbridge/service/ThumbnailService.java | 4 +- .../lensbridge/service/UploadService.java | 8 ++-- .../lensbridge/service/EventServiceTests.java | 7 ++-- 22 files changed, 109 insertions(+), 124 deletions(-) rename src/main/java/com/ibrasoft/lensbridge/model/{event => upload}/Event.java (94%) rename src/main/java/com/ibrasoft/lensbridge/model/{event => upload}/EventStatus.java (74%) rename src/main/java/com/ibrasoft/lensbridge/model/{media => upload}/Upload.java (56%) rename src/main/java/com/ibrasoft/lensbridge/model/{media => upload}/UploadType.java (88%) create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 55c2d4a..e33b1da 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -8,8 +8,8 @@ import com.ibrasoft.lensbridge.dto.response.MessageResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; +import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.EventsService; import com.ibrasoft.lensbridge.service.UploadService; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java index 5506d81..f4af638 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; +import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.service.EventsService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index 724de02..35d1662 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -4,7 +4,7 @@ import com.ibrasoft.lensbridge.dto.response.*; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.auth.Role; -import com.ibrasoft.lensbridge.model.media.Upload; +import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.DirectUploadService; import com.ibrasoft.lensbridge.service.UploadService; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java index f50ccb3..c489d48 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java @@ -7,7 +7,7 @@ import java.time.LocalDateTime; import java.util.UUID; -import com.ibrasoft.lensbridge.model.media.UploadType; +import com.ibrasoft.lensbridge.model.upload.UploadType; @Data @NoArgsConstructor diff --git a/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java similarity index 94% rename from src/main/java/com/ibrasoft/lensbridge/model/event/Event.java rename to src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java index b99713b..37192a3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/event/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.model.event; +package com.ibrasoft.lensbridge.model.upload; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/event/EventStatus.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/EventStatus.java similarity index 74% rename from src/main/java/com/ibrasoft/lensbridge/model/event/EventStatus.java rename to src/main/java/com/ibrasoft/lensbridge/model/upload/EventStatus.java index 4f30841..31b1bbc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/event/EventStatus.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/EventStatus.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.model.event; +package com.ibrasoft.lensbridge.model.upload; public enum EventStatus { UPCOMING("Upcoming"), diff --git a/src/main/java/com/ibrasoft/lensbridge/model/media/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java similarity index 56% rename from src/main/java/com/ibrasoft/lensbridge/model/media/Upload.java rename to src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index 9195fd9..9bf59d4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/media/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -1,15 +1,24 @@ -package com.ibrasoft.lensbridge.model.media; +package com.ibrasoft.lensbridge.model.upload; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; + import org.springframework.data.annotation.Id; +import com.ibrasoft.lensbridge.model.auth.User; + +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; @@ -17,7 +26,8 @@ @Entity @Table(name = "devices") -@Data +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor /** @@ -29,16 +39,22 @@ public class Upload { @GeneratedValue(strategy = GenerationType.UUID) private UUID uuid; - private String fileName; + private String fileName; private String fileUrl; private String thumbnailUrl; private String uploadDescription; private String instagramHandle; - private UUID uploadedBy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploaded_by", nullable = false) + private User uploadedBy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id") + private Event event; - private UUID eventId; private Instant createdDate; private boolean approved; @@ -47,4 +63,14 @@ public class Upload { @Enumerated(EnumType.STRING) private UploadType contentType; + + @Column(nullable = false) + /** + * hehe we don't delete things anymore + * DB is append-only, we just mark things as deleted and filter them out in queries + * deletedAt = null => not deleted + */ + private Instant deletedAt; + @ManyToOne(fetch = FetchType.LAZY) + private User deletedBy; } \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/model/media/UploadType.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/UploadType.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/model/media/UploadType.java rename to src/main/java/com/ibrasoft/lensbridge/model/upload/UploadType.java index 850e1e5..b7d385f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/media/UploadType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/UploadType.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.model.media; +package com.ibrasoft.lensbridge.model.upload; public enum UploadType { IMAGE("image"), diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java new file mode 100644 index 0000000..25a0b2a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java @@ -0,0 +1,12 @@ +package com.ibrasoft.lensbridge.repository.audit; + +import com.ibrasoft.lensbridge.model.audit.AuditEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface AuditEventRepository extends JpaRepository { + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java deleted file mode 100644 index 7de47ed..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/media/UploadRepository.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.ibrasoft.lensbridge.repository.media; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import com.ibrasoft.lensbridge.model.media.Upload; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface UploadRepository extends MongoRepository { - Page findByEventId(UUID eventId, Pageable pageable); - - Page findByApprovedTrue(Pageable pageable); - - // Admin-specific query methods - Page findByApproved(boolean approved, Pageable pageable); - - Page findByFeatured(boolean featured, Pageable pageable); - - Page findByApprovedTrueAndFeaturedTrue(Pageable pageable); - - Page findByApprovedTrueAndFeaturedFalse(Pageable pageable); - - Page findByUploadedBy(UUID uploadedBy, Pageable pageable); - - // Count methods for user statistics - long countByUploadedBy(UUID uploadedBy); - - long countByUploadedByAndApproved(UUID uploadedBy, boolean approved); - - long countByUploadedByAndFeatured(UUID uploadedBy, boolean featured); - - // Count uploads by user within date range for daily limits - long countByUploadedByAndCreatedDateBetween(UUID uploadedBy, LocalDateTime start, LocalDateTime end); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java deleted file mode 100644 index 02e4640..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/AuditEventRepository.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ibrasoft.lensbridge.repository.mongo; - -import com.ibrasoft.lensbridge.model.audit.AuditAction; -import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.data.mongodb.repository.Query; -import org.springframework.stereotype.Repository; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -@Repository -public interface AuditEventRepository extends MongoRepository { - - List findByAdminIdOrderByTimestampDesc(UUID adminId); - - List findByEntityTypeAndEntityIdOrderByTimestampDesc(String entityType, UUID entityId); - - Page findByActionOrderByTimestampDesc(AuditAction action, Pageable pageable); - - @Query("{'timestamp': {'$gte': ?0, '$lte': ?1}}") - Page findByTimestampBetweenOrderByTimestampDesc(Instant start, Instant end, Pageable pageable); - - @Query("{'result': {'$regex': '^ERROR|FAILED', '$options': 'i'}}") - Page findFailedOperationsOrderByTimestampDesc(Pageable pageable); - - Page findAllByOrderByTimestampDesc(Pageable pageable); - - long countByAdminId(UUID adminId); - - long countByAction(AuditAction action); -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java deleted file mode 100644 index 999ba21..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/repository/mongo/EventsRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ibrasoft.lensbridge.repository.mongo; - -import com.ibrasoft.lensbridge.model.event.Event; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface EventsRepository extends MongoRepository { - Optional findEventById(UUID uuid); -} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java new file mode 100644 index 0000000..941f006 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java @@ -0,0 +1,14 @@ +package com.ibrasoft.lensbridge.repository.upload; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.upload.Event; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface EventsRepository extends JpaRepository { + Optional findEventById(UUID uuid); +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java new file mode 100644 index 0000000..3ecaa5b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java @@ -0,0 +1,19 @@ +package com.ibrasoft.lensbridge.repository.upload; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import com.ibrasoft.lensbridge.model.upload.Upload; + +import java.util.UUID; + +@Repository +public interface UploadRepository extends JpaRepository { + /** + * Recall that deletedAt = null => not deleted. Therefore, this method will only return uploads that are not marked as deleted. + */ + Page findByDeletedAtIsNull(Pageable pageable); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index 7aeaa24..465498b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -2,7 +2,8 @@ import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import com.ibrasoft.lensbridge.repository.mongo.AuditEventRepository; +import com.ibrasoft.lensbridge.repository.audit.AuditEventRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java index 9744d73..e6c47d6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java @@ -7,7 +7,7 @@ import com.ibrasoft.lensbridge.dto.response.PresignedUploadResponse; import com.ibrasoft.lensbridge.dto.response.UploadCompletionResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.media.Upload; +import com.ibrasoft.lensbridge.model.upload.Upload; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index df9e3ee..9281235 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -1,8 +1,9 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; +import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.EventStatus; +import com.ibrasoft.lensbridge.repository.upload.EventsRepository; + import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 24ea55d..63d6612 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -2,10 +2,10 @@ import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.media.Upload; -import com.ibrasoft.lensbridge.repository.media.UploadRepository; -import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; +import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.repository.upload.EventsRepository; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java index d36d12c..8e8984f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.media.Upload; -import com.ibrasoft.lensbridge.model.media.UploadType; -import com.ibrasoft.lensbridge.repository.media.UploadRepository; +import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.model.upload.UploadType; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java index dcff79d..11bbcb3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.media.Upload; -import com.ibrasoft.lensbridge.repository.media.UploadRepository; +import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index d3c9a30..c63e818 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -5,10 +5,10 @@ import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.media.Upload; -import com.ibrasoft.lensbridge.model.media.UploadType; -import com.ibrasoft.lensbridge.repository.media.UploadRepository; +import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.model.upload.UploadType; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java b/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java index 1e5ae18..f15503c 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java @@ -1,8 +1,9 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.repository.mongo.EventsRepository; +import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.EventStatus; +import com.ibrasoft.lensbridge.repository.upload.EventsRepository; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; From 878efc43640f06ea315080dd909f7a1790e68bc3 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 15:39:19 -0400 Subject: [PATCH 11/52] moved files around packages --- .../controller/AdminController.java | 6 +++--- .../controller/AgentEnrollmentController.java | 6 +++--- .../lensbridge/controller/AuthController.java | 4 ++-- .../controller/BoardAdminController.java | 14 ++++++------- .../controller/DeviceAdminController.java | 14 ++++++------- .../controller/FileUploadController.java | 2 +- .../controller/GalleryController.java | 2 +- .../controller/MusallahBoardController.java | 2 +- .../lensbridge/controller/UserController.java | 10 +++++----- .../request/ChangePasswordRequest.java | 6 +++--- .../dto/{ => auth}/request/LoginRequest.java | 2 +- .../dto/{ => auth}/request/SignupRequest.java | 2 +- .../request/TokenRefreshRequest.java | 2 +- .../request/UpdateProfileRequest.java | 2 +- .../dto/{ => auth}/response/JwtResponse.java | 2 +- .../{ => auth}/response/MessageResponse.java | 2 +- .../response/TokenRefreshResponse.java | 2 +- .../response/TokenValidationResponse.java | 2 +- .../{ => auth}/response/UserInfoResponse.java | 2 +- .../response/UserStatsResponse.java | 2 +- .../dto/{ => board}/agent/AuthFrame.java | 2 +- .../{ => board}/agent/CommandAckFrame.java | 2 +- .../agent/CommandProgressFrame.java | 2 +- .../{ => board}/agent/CommandResultFrame.java | 2 +- .../dto/{ => board}/agent/HeartbeatFrame.java | 2 +- .../{ => board}/agent/IncomingAgentFrame.java | 2 +- .../{ => board}/agent/OutgoingAgentFrame.java | 2 +- .../request/AgentEnrollRequest.java | 2 +- .../request/CreateCalendarEventRequest.java | 6 +++++- .../request/CreatePosterRequest.java | 2 +- .../request/IssueCommandRequest.java | 2 +- .../request/IssueEnrollmentTokenRequest.java | 2 +- .../request/UpdateBoardConfigRequest.java | 2 +- .../request/UpdateCalendarEventRequest.java | 2 +- .../request/UpdatePosterRequest.java | 2 +- .../request/WeeklyContentRequest.java | 2 +- .../response/AgentEnrollResponse.java | 2 +- .../response/CommandIssuedResponse.java | 2 +- .../dto/{ => board}/response/CommandView.java | 2 +- .../{ => board}/response/DeviceSummary.java | 2 +- .../{ => board}/response/DisplayConfig.java | 2 +- .../IssueEnrollmentTokenResponse.java | 2 +- .../response/MusallahBoardPayload.java | 2 +- .../response/frames/EventView.java | 2 +- .../dto/upload/request/CreateEventDto.java | 20 +++++++++++++++++++ .../{ => upload}/response/AdminDataDto.java | 2 +- .../{ => upload}/response/AdminUploadDto.java | 2 +- .../response/DailyLimitErrorResponse.java | 2 +- .../{ => upload}/response/ErrorResponse.java | 2 +- .../response/FileSizeErrorResponse.java | 2 +- .../{ => upload}/response/GalleryItemDto.java | 2 +- .../response/PresignedUploadResponse.java | 2 +- .../response/UploadCompletionResponse.java | 2 +- .../response/UploadLimitsResponse.java | 2 +- .../exception/GlobalExceptionHandler.java | 2 +- .../handler/AgentWebSocketHandler.java | 14 ++++++------- .../frames/DailyScheduleFrameConfig.java | 2 +- .../board/frames/EventListFrameConfig.java | 2 +- .../lensbridge/service/AdminAuditService.java | 2 +- .../lensbridge/service/BoardService.java | 10 +++++----- .../service/DirectUploadService.java | 10 +++++----- .../lensbridge/service/EventsService.java | 20 ++++++++----------- .../lensbridge/service/GalleryService.java | 2 +- .../lensbridge/service/PosterService.java | 6 +++--- .../lensbridge/service/UploadService.java | 6 +++--- .../lensbridge/service/UserService.java | 6 +++--- .../service/agent/AgentSession.java | 2 +- .../service/agent/CommandDispatcher.java | 12 +++++------ .../service/agent/HeartbeatService.java | 2 +- .../agent/events/DeviceEventPublisher.java | 6 +++--- .../service/board/BoardPayloadAssembler.java | 4 ++-- .../DailyScheduleFrameTransformer.java | 2 +- .../EventListFrameTransformer.java | 2 +- .../service/agent/CommandDispatcherTest.java | 8 ++++---- 74 files changed, 158 insertions(+), 138 deletions(-) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/request/ChangePasswordRequest.java (69%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/request/LoginRequest.java (87%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/request/SignupRequest.java (95%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/request/TokenRefreshRequest.java (80%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/request/UpdateProfileRequest.java (92%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/response/JwtResponse.java (88%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/response/MessageResponse.java (73%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/response/TokenRefreshResponse.java (81%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/response/TokenValidationResponse.java (87%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/response/UserInfoResponse.java (86%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => auth}/response/UserStatsResponse.java (82%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/AuthFrame.java (93%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/CommandAckFrame.java (84%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/CommandProgressFrame.java (88%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/CommandResultFrame.java (92%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/HeartbeatFrame.java (93%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/IncomingAgentFrame.java (96%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/agent/OutgoingAgentFrame.java (97%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/AgentEnrollRequest.java (89%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/CreateCalendarEventRequest.java (70%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/CreatePosterRequest.java (93%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/IssueCommandRequest.java (93%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/IssueEnrollmentTokenRequest.java (90%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/UpdateBoardConfigRequest.java (92%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/UpdateCalendarEventRequest.java (88%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/UpdatePosterRequest.java (89%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/request/WeeklyContentRequest.java (91%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/AgentEnrollResponse.java (83%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/CommandIssuedResponse.java (84%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/CommandView.java (96%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/DeviceSummary.java (95%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/DisplayConfig.java (87%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/IssueEnrollmentTokenResponse.java (90%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/MusallahBoardPayload.java (89%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => board}/response/frames/EventView.java (83%) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/AdminDataDto.java (81%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/AdminUploadDto.java (97%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/DailyLimitErrorResponse.java (91%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/ErrorResponse.java (87%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/FileSizeErrorResponse.java (90%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/GalleryItemDto.java (88%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/PresignedUploadResponse.java (90%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/UploadCompletionResponse.java (88%) rename src/main/java/com/ibrasoft/lensbridge/dto/{ => upload}/response/UploadLimitsResponse.java (91%) diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index e33b1da..e6abb9a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -3,9 +3,9 @@ import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import com.ibrasoft.lensbridge.dto.request.SignupRequest; -import com.ibrasoft.lensbridge.dto.response.AdminUploadDto; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Event; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java index 9a6e602..404066e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.request.AgentEnrollRequest; -import com.ibrasoft.lensbridge.dto.response.AgentEnrollResponse; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.board.request.AgentEnrollRequest; +import com.ibrasoft.lensbridge.dto.board.response.AgentEnrollResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index f94003f..45bbe5d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -24,8 +24,8 @@ import com.ibrasoft.lensbridge.model.auth.*; -import com.ibrasoft.lensbridge.dto.request.*; -import com.ibrasoft.lensbridge.dto.response.*; +import com.ibrasoft.lensbridge.dto.auth.request.*; +import com.ibrasoft.lensbridge.dto.auth.response.*; import com.ibrasoft.lensbridge.security.jwt.JwtUtils; import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 420053d..9ea9b2b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -1,12 +1,12 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.request.CreateCalendarEventRequest; -import com.ibrasoft.lensbridge.dto.request.CreatePosterRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateBoardConfigRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateCalendarEventRequest; -import com.ibrasoft.lensbridge.dto.request.UpdatePosterRequest; -import com.ibrasoft.lensbridge.dto.request.WeeklyContentRequest; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.handler.SignboardHandler; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index 9784360..d547a1a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -1,13 +1,13 @@ package com.ibrasoft.lensbridge.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import com.ibrasoft.lensbridge.dto.request.IssueCommandRequest; -import com.ibrasoft.lensbridge.dto.request.IssueEnrollmentTokenRequest; -import com.ibrasoft.lensbridge.dto.response.CommandIssuedResponse; -import com.ibrasoft.lensbridge.dto.response.CommandView; -import com.ibrasoft.lensbridge.dto.response.DeviceSummary; -import com.ibrasoft.lensbridge.dto.response.IssueEnrollmentTokenResponse; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.board.request.IssueEnrollmentTokenRequest; +import com.ibrasoft.lensbridge.dto.board.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.dto.board.response.CommandView; +import com.ibrasoft.lensbridge.dto.board.response.DeviceSummary; +import com.ibrasoft.lensbridge.dto.board.response.IssueEnrollmentTokenResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index 35d1662..30e66e0 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.controller; import com.ibrasoft.lensbridge.config.UploadProperties; -import com.ibrasoft.lensbridge.dto.response.*; +import com.ibrasoft.lensbridge.dto.upload.response.*; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.upload.Upload; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java index 635d4e1..ab0eeb2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.service.GalleryService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index 584ac68..41824e8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Event; diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java index efee40a..5051950 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java @@ -20,11 +20,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.ibrasoft.lensbridge.dto.request.UpdateProfileRequest; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; -import com.ibrasoft.lensbridge.dto.response.UserInfoResponse; -import com.ibrasoft.lensbridge.dto.response.UserStatsResponse; +import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; +import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/ChangePasswordRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ChangePasswordRequest.java similarity index 69% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/ChangePasswordRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ChangePasswordRequest.java index 391c497..9347e1a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/ChangePasswordRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ChangePasswordRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -7,10 +7,10 @@ @Data public class ChangePasswordRequest { @NotBlank - @Size(min = 6, max = 40) + @Size(min = 6, max = 256) private String currentPassword; @NotBlank - @Size(min = 6, max = 40) + @Size(min = 6, max = 256) private String newPassword; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java similarity index 87% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java index 6722eba..0531f33 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java similarity index 95% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java index b623778..4ec4785 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import lombok.Data; import jakarta.validation.constraints.*; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java similarity index 80% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java index e2a656d..b4eb877 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java similarity index 92% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java index c136e24..d7688bf 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.auth.request; import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java index 45fa3c1..9320aef 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import java.util.List; import java.util.UUID; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/MessageResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/MessageResponse.java similarity index 73% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/MessageResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/MessageResponse.java index fc02ed6..f8a0b86 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/MessageResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/MessageResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenRefreshResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenRefreshResponse.java similarity index 81% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/TokenRefreshResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenRefreshResponse.java index 56ebf38..cf84158 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenRefreshResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenRefreshResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenValidationResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java similarity index 87% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/TokenValidationResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java index a8cc459..d3540c8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/TokenValidationResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import java.util.List; import java.util.UUID; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserInfoResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java similarity index 86% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UserInfoResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java index e274a73..0913886 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserInfoResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import java.util.List; import java.util.UUID; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserStatsResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserStatsResponse.java similarity index 82% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UserStatsResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserStatsResponse.java index a8b6f3e..5123bdb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UserStatsResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserStatsResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.auth.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/AuthFrame.java similarity index 93% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/AuthFrame.java index 534f1b8..7b3002b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/AuthFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/AuthFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import lombok.EqualsAndHashCode; import lombok.Getter; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java similarity index 84% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java index 3410ed0..168390d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandAckFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java index ca2bfe2..6c2cb98 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandProgressFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java similarity index 92% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java index 5bf3931..d9a7d79 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/CommandResultFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import com.fasterxml.jackson.databind.JsonNode; import lombok.Getter; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java similarity index 93% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java index 275e3fa..83d58f7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/HeartbeatFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import lombok.EqualsAndHashCode; import lombok.Getter; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/IncomingAgentFrame.java similarity index 96% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/IncomingAgentFrame.java index 226797a..b2292fb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/IncomingAgentFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/IncomingAgentFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/OutgoingAgentFrame.java similarity index 97% rename from src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/agent/OutgoingAgentFrame.java index e218a1e..e81f2eb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/agent/OutgoingAgentFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/OutgoingAgentFrame.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.agent; +package com.ibrasoft.lensbridge.dto.board.agent; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/AgentEnrollRequest.java similarity index 89% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/AgentEnrollRequest.java index f048140..d7fbef9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/AgentEnrollRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/AgentEnrollRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java similarity index 70% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java index 66bc91e..63132de 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java @@ -1,8 +1,9 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Audience; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.*; import java.time.Instant; @@ -11,7 +12,10 @@ public class CreateCalendarEventRequest { @NotBlank(message = "Event name is required") private String name; + @NotBlank(message = "Event description is required") private String description; + @NotBlank(message = "Event location is required") + @Size(max = 255, message = "Location must be less than 255 characters") private String location; @NotNull(message = "Start time is required") private Instant startTime; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java similarity index 93% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java index dd7300f..f75bdde 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/CreatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Audience; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java similarity index 93% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java index e7a7dbd..699edcd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueCommandRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.fasterxml.jackson.databind.JsonNode; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java similarity index 90% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java index 80c67ab..82505a3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/IssueEnrollmentTokenRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Audience; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java similarity index 92% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java index 0966639..701230e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Location; import jakarta.validation.constraints.Min; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateCalendarEventRequest.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateCalendarEventRequest.java index a0463ad..ab8cd60 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateCalendarEventRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Audience; import lombok.*; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java similarity index 89% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java index 0e3a3d5..de3ea33 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Audience; import jakarta.validation.constraints.Positive; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java similarity index 91% rename from src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java index d5fbd7d..b87ea29 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/request/WeeklyContentRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.request; +package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.IslamicQuote; import lombok.*; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java similarity index 83% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java index 179ef20..9e94285 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AgentEnrollResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import lombok.Builder; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java similarity index 84% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java index 9ff2beb..c11d2ba 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandIssuedResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java similarity index 96% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java index e2c1c9f..284cb36 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/CommandView.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java similarity index 95% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java index 3210202..bee555e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/DeviceSummary.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Device; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java similarity index 87% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java index f29e8ca..56558b9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/DisplayConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import lombok.*; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/IssueEnrollmentTokenResponse.java similarity index 90% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/IssueEnrollmentTokenResponse.java index 6a80d06..98bfe5a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/IssueEnrollmentTokenResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/IssueEnrollmentTokenResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import lombok.Builder; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java similarity index 89% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java index 9ad56a2..986f5b8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/MusallahBoardPayload.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.board.response; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java similarity index 83% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java rename to src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java index 69ca1d3..8472142 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/frames/EventView.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response.frames; +package com.ibrasoft.lensbridge.dto.board.response.frames; import lombok.*; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java new file mode 100644 index 0000000..b4a759b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/request/CreateEventDto.java @@ -0,0 +1,20 @@ +package com.ibrasoft.lensbridge.dto.upload.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class CreateEventDto { + @NotBlank(message = "Event name is required") + private String name; + + @NotNull(message = "Event date is required") + private Instant date; + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminDataDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminDataDto.java similarity index 81% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/AdminDataDto.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminDataDto.java index 6c39c37..06b3063 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminDataDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminDataDto.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java similarity index 97% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java index c489d48..34965ae 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/AdminUploadDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/DailyLimitErrorResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/DailyLimitErrorResponse.java similarity index 91% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/DailyLimitErrorResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/DailyLimitErrorResponse.java index 8113cc2..5900d36 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/DailyLimitErrorResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/DailyLimitErrorResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/ErrorResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/ErrorResponse.java similarity index 87% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/ErrorResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/ErrorResponse.java index b01d28c..714f20f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/ErrorResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/FileSizeErrorResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/FileSizeErrorResponse.java similarity index 90% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/FileSizeErrorResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/FileSizeErrorResponse.java index 35d61c1..cd0bde3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/FileSizeErrorResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/FileSizeErrorResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/GalleryItemDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/GalleryItemDto.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/GalleryItemDto.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/GalleryItemDto.java index 8a30ff5..8975d20 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/GalleryItemDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/GalleryItemDto.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/PresignedUploadResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/PresignedUploadResponse.java similarity index 90% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/PresignedUploadResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/PresignedUploadResponse.java index ead3d24..759434a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/PresignedUploadResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/PresignedUploadResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadCompletionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadCompletionResponse.java similarity index 88% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UploadCompletionResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadCompletionResponse.java index c99fdf5..02fd660 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadCompletionResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadCompletionResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadLimitsResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadLimitsResponse.java similarity index 91% rename from src/main/java/com/ibrasoft/lensbridge/dto/response/UploadLimitsResponse.java rename to src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadLimitsResponse.java index 92bdcb8..9cae6a7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/response/UploadLimitsResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadLimitsResponse.java @@ -1,4 +1,4 @@ -package com.ibrasoft.lensbridge.dto.response; +package com.ibrasoft.lensbridge.dto.upload.response; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java index 86718e7..b4c0a84 100644 --- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.exception; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java index 3b92bb1..2335945 100644 --- a/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandler.java @@ -1,13 +1,13 @@ package com.ibrasoft.lensbridge.handler; import com.fasterxml.jackson.databind.ObjectMapper; -import com.ibrasoft.lensbridge.dto.agent.AuthFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandAckFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandProgressFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; -import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; -import com.ibrasoft.lensbridge.dto.agent.IncomingAgentFrame; -import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.dto.board.agent.AuthFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.dto.board.agent.IncomingAgentFrame; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.agent.AgentSession; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java index ad706d7..50888f4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.model.board.frames; -import com.ibrasoft.lensbridge.dto.response.frames.EventView; +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; import lombok.*; import java.util.List; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java index e4b1de4..5f5c962 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.model.board.frames; -import com.ibrasoft.lensbridge.dto.response.frames.EventView; +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; import lombok.*; import java.util.List; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index 465498b..fa302da 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -25,7 +25,7 @@ public AuditEvent logAuditEvent(AuditEvent event) { try { event.setTimestamp(Instant.now()); AuditEvent savedEvent = auditEventRepository.save(event); - log.debug("Audit event saved: {} by {} on {}", event.getAction(), event.getAdminEmail(), event.getEntityType()); + log.debug("Audit event saved: {} by {} on {}", event.getAction(), event.getAdmin().getEmail(), event.getTargetEntityType()); return savedEvent; } catch (Exception e) { log.error("Failed to save audit event: {}", e.getMessage(), e); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 1c83bbe..7f152c6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -1,10 +1,10 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.request.CreateCalendarEventRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateBoardConfigRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateCalendarEventRequest; -import com.ibrasoft.lensbridge.dto.request.WeeklyContentRequest; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.*; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java index e6c47d6..588707f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java @@ -1,11 +1,11 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.config.UploadProperties; -import com.ibrasoft.lensbridge.dto.response.DailyLimitErrorResponse; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; -import com.ibrasoft.lensbridge.dto.response.FileSizeErrorResponse; -import com.ibrasoft.lensbridge.dto.response.PresignedUploadResponse; -import com.ibrasoft.lensbridge.dto.response.UploadCompletionResponse; +import com.ibrasoft.lensbridge.dto.upload.response.DailyLimitErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.FileSizeErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.upload.Upload; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index 9281235..40d9f9b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.service; +import com.ibrasoft.lensbridge.dto.upload.request.CreateEventDto; import com.ibrasoft.lensbridge.model.upload.Event; import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.repository.upload.EventsRepository; @@ -20,18 +21,13 @@ public class EventsService { private final EventsRepository eventsRepository; - public Event createEvent(Event event) { - if (event.getId() == null) { - event.setId(UUID.randomUUID()); - } - Event saved = eventsRepository.save(event); - this.cleanUpOldEvents(); - return saved; - } - - public Event createEvent(String name, LocalDateTime date) { - Event event = Event.builder().id(UUID.randomUUID()).name(name).date(date).status(EventStatus.UPCOMING).build(); - return createEvent(event); + public Event createEvent(CreateEventDto event) { + Event newEvent = Event.builder() + .name(event.getName()) + .date(event.getDate()) + .build(); + + return eventsRepository.save(newEvent); } public List getAllEvents() { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 63d6612..7efd9e6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Event; import com.ibrasoft.lensbridge.model.upload.Upload; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index d8a3c8e..b284bc6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.request.CreatePosterRequest; -import com.ibrasoft.lensbridge.dto.request.UpdatePosterRequest; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index c63e818..585433a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.dto.response.AdminUploadDto; -import com.ibrasoft.lensbridge.dto.response.UserStatsResponse; -import com.ibrasoft.lensbridge.dto.response.GalleryItemDto; +import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; +import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Event; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index f7a9cbf..bc7b40f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -10,9 +10,9 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import com.ibrasoft.lensbridge.dto.request.ChangePasswordRequest; -import com.ibrasoft.lensbridge.dto.request.SignupRequest; -import com.ibrasoft.lensbridge.dto.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.repository.auth.UserRepository; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java index 2fb4bbc..4bba70e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/AgentSession.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.web.socket.CloseStatus; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java index e5b62a5..ad5cd95 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java @@ -3,12 +3,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.NullNode; -import com.ibrasoft.lensbridge.dto.agent.CommandAckFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandProgressFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; -import com.ibrasoft.lensbridge.dto.agent.OutgoingAgentFrame; -import com.ibrasoft.lensbridge.dto.request.IssueCommandRequest; -import com.ibrasoft.lensbridge.dto.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.dto.board.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.board.response.CommandIssuedResponse; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.DeviceCommand; import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java index 6d37a76..6340020 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.service.agent; -import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java index aa25e82..12fb1a0 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java @@ -1,8 +1,8 @@ package com.ibrasoft.lensbridge.service.agent.events; -import com.ibrasoft.lensbridge.dto.agent.CommandProgressFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; -import com.ibrasoft.lensbridge.dto.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandProgressFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; import com.ibrasoft.lensbridge.model.board.DeviceCommand; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index 8d550d3..e5eb4bf 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service.board; -import com.ibrasoft.lensbridge.dto.response.ErrorResponse; -import com.ibrasoft.lensbridge.dto.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.Event; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java index d710c61..db14000 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.service.board.transformer; -import com.ibrasoft.lensbridge.dto.response.frames.EventView; +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java index 03aa90d..375d723 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.service.board.transformer; -import com.ibrasoft.lensbridge.dto.response.frames.EventView; +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java index 2f79879..d59a7e9 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java @@ -3,10 +3,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.ibrasoft.lensbridge.dto.agent.CommandAckFrame; -import com.ibrasoft.lensbridge.dto.agent.CommandResultFrame; -import com.ibrasoft.lensbridge.dto.request.IssueCommandRequest; -import com.ibrasoft.lensbridge.dto.response.CommandIssuedResponse; +import com.ibrasoft.lensbridge.dto.board.agent.CommandAckFrame; +import com.ibrasoft.lensbridge.dto.board.agent.CommandResultFrame; +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.dto.board.response.CommandIssuedResponse; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.DeviceCommand; From afcf60dc835c05e158a99be0edc36e10dbd41c35 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 15:42:17 -0400 Subject: [PATCH 12/52] removed stale test files --- .../lensbridge/controller/AuthController.java | 4 +- .../AuthControllerIntegrationTest.java | 3 - .../EventsControllerIntegrationTest.java | 3 - .../FileUploadControllerIntegrationTest.java | 3 - .../GalleryControllerIntegrationTest.java | 3 - .../lensbridge/service/EventServiceTests.java | 112 ------------------ .../service/UploadServiceIntegrationTest.java | 0 7 files changed, 2 insertions(+), 126 deletions(-) delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerIntegrationTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerIntegrationTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/FileUploadControllerIntegrationTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerIntegrationTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/service/UploadServiceIntegrationTest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index 45bbe5d..1e0d93c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -93,14 +93,14 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login RefreshToken refreshToken = refreshTokenService.createRefreshToken( userDetails.getId(), deviceInfo, ipAddress); - log.debug("Created refresh token for user {}: {}", userDetails.getId(), refreshToken.getToken()); + log.debug("Created refresh token for user {}: {}", userDetails.getId(), refreshToken.getTokenHash()); List roles = userDetails.getAuthorities().stream() .map(item -> item.getAuthority()) .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, - refreshToken.getToken(), + refreshToken.getTokenHash(), userDetails.getFirstName(), userDetails.getLastName(), userDetails.getId(), diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerIntegrationTest.java deleted file mode 100644 index 40b7c93..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerIntegrationTest.java +++ /dev/null @@ -1,3 +0,0 @@ -public class AuthControllerIntegrationTest { - -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerIntegrationTest.java deleted file mode 100644 index 969e038..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerIntegrationTest.java +++ /dev/null @@ -1,3 +0,0 @@ -public class EventsControllerIntegrationTest { - -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/FileUploadControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/FileUploadControllerIntegrationTest.java deleted file mode 100644 index 8ab3ade..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/FileUploadControllerIntegrationTest.java +++ /dev/null @@ -1,3 +0,0 @@ -public class FileUploadControllerIntegrationTest { - -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerIntegrationTest.java deleted file mode 100644 index 562b1ee..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerIntegrationTest.java +++ /dev/null @@ -1,3 +0,0 @@ -public class GalleryControllerIntegrationTest { - -} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java b/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java deleted file mode 100644 index f15503c..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/service/EventServiceTests.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.model.upload.Event; -import com.ibrasoft.lensbridge.model.upload.EventStatus; -import com.ibrasoft.lensbridge.repository.upload.EventsRepository; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class EventServiceTests { - - @Mock - private EventsRepository eventsRepository; - - @InjectMocks - private EventsService eventService; - - @Test - void testPastEventGetsMarkedAsPast() { - Event pastEvent = new Event(); - pastEvent.setDate(LocalDateTime.of(2023, 12, 1, 10, 0)); - - when(eventsRepository.findAll()).thenReturn(List.of(pastEvent)); - - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 28, 12, 0)); - - assertEquals(EventStatus.PAST, pastEvent.getStatus()); - verify(eventsRepository).save(pastEvent); - } - - @Test - void testUpcomingEventGetsMarkedAsUpcoming() { - Event futureEvent = new Event(); - futureEvent.setDate(LocalDateTime.of(2025, 12, 25, 10, 0)); - - when(eventsRepository.findAll()).thenReturn(List.of(futureEvent)); - - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 28, 12, 0)); - - assertEquals(EventStatus.UPCOMING, futureEvent.getStatus()); - verify(eventsRepository).save(futureEvent); - } - - @Test - void testOngoingEventGetsMarkedAsOngoing() { - Event ongoingEvent = new Event(); - ongoingEvent.setDate(LocalDateTime.of(2025, 7, 28, 8, 0)); - - when(eventsRepository.findAll()).thenReturn(List.of(ongoingEvent)); - - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 28, 12, 0)); - - assertEquals(EventStatus.ONGOING, ongoingEvent.getStatus()); - verify(eventsRepository).save(ongoingEvent); - } - - @Test - void testEventTodayButHasNotStartedIsUpcoming() { - Event upcomingToday = new Event(); - upcomingToday.setDate(LocalDateTime.of(2025, 7, 28, 18, 0)); // later today - - when(eventsRepository.findAll()).thenReturn(List.of(upcomingToday)); - - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 28, 10, 0)); - - assertEquals(EventStatus.UPCOMING, upcomingToday.getStatus()); - verify(eventsRepository).save(upcomingToday); - } - - @Test - void testNullDateIsIgnored() { - Event event = new Event(); - event.setDate(null); - - when(eventsRepository.findAll()).thenReturn(List.of(event)); - - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 28, 10, 0)); - - verify(eventsRepository, never()).save(any()); - } - - @Test - void testEventTodayGetsMarkedAsOngoingAndStaysUntilNextDay() { - Event todayEvent = new Event(); - todayEvent.setDate(LocalDateTime.of(2025, 7, 28, 10, 0)); // today at 10 AM - - when(eventsRepository.findAll()).thenReturn(List.of(todayEvent)); - - // First cleanup during the same day (e.g., 12 PM) - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 28, 12, 0)); - assertEquals(EventStatus.ONGOING, todayEvent.getStatus()); - - // Then cleanup the next day at 12:01 AM - eventService.cleanUpOldEvents(LocalDateTime.of(2025, 7, 29, 0, 1)); - assertEquals(EventStatus.PAST, todayEvent.getStatus()); - - // Make sure save was called twice - verify(eventsRepository, times(2)).save(todayEvent); - } - -} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/UploadServiceIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/service/UploadServiceIntegrationTest.java deleted file mode 100644 index e69de29..0000000 From 8e91e4f87df269ef7dad83ab386c4730105847bd Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 16:00:07 -0400 Subject: [PATCH 13/52] dear god what was I smoking when I first built this --- .../controller/AdminController.java | 16 ++- .../lensbridge/controller/AuthController.java | 134 ++++++++++-------- .../controller/BoardAdminController.java | 28 ++-- .../controller/DeviceAdminController.java | 18 +-- .../controller/FileUploadController.java | 29 +++- .../lensbridge/controller/UserController.java | 40 ++++-- .../dto/auth/response/UserInfoResponse.java | 5 +- .../lensbridge/security/jwt/JwtUtils.java | 7 +- .../security/services/UserDetailsImpl.java | 67 --------- .../services/UserDetailsServiceImpl.java | 15 +- .../controller/DeviceAdminControllerTest.java | 13 +- 11 files changed, 175 insertions(+), 197 deletions(-) delete mode 100644 src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index e6abb9a..87c3614 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -10,7 +10,6 @@ import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Event; import com.ibrasoft.lensbridge.model.upload.EventStatus; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.EventsService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; @@ -23,6 +22,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; @@ -47,15 +47,13 @@ public class AdminController { private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(uploadId); - UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - this.auditService.logAuditEvent(curr.getEmail(), auditAction, "Upload", uploadId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "Upload", uploadId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(userId); - UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - this.auditService.logAuditEvent(curr.getEmail(), auditAction, "User", userId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "User", userId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } @@ -64,10 +62,9 @@ private ResponseEntity executeCreationAction(T input, HttpServletReque String entityType, Function idExtractor, String successMessage) { R createdEntity = serviceAction.apply(input); - UserDetailsImpl curr = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); UUID entityId = idExtractor.apply(createdEntity); - this.auditService.logAuditEvent(curr.getEmail(), auditAction, entityType, entityId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, entityType, entityId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } @@ -259,6 +256,11 @@ public ResponseEntity getFailedOperations(Pageable pageable) { } } + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; + } + @GetMapping("/audit/upload/{uploadId}") public ResponseEntity getUploadAuditHistory(@PathVariable UUID uploadId) { try { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index 1e0d93c..a2dc922 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -5,6 +5,7 @@ import java.util.UUID; import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.slf4j.Logger; @@ -14,6 +15,8 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -22,25 +25,26 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.ibrasoft.lensbridge.model.auth.*; - -import com.ibrasoft.lensbridge.dto.auth.request.*; -import com.ibrasoft.lensbridge.dto.auth.response.*; - -import com.ibrasoft.lensbridge.security.jwt.JwtUtils; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.LoginRequest; +import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.TokenRefreshRequest; +import com.ibrasoft.lensbridge.dto.auth.response.JwtResponse; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.response.TokenRefreshResponse; +import com.ibrasoft.lensbridge.dto.auth.response.TokenValidationResponse; +import com.ibrasoft.lensbridge.model.auth.RefreshToken; +import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.security.LoginAttemptService; -import com.ibrasoft.lensbridge.service.UserService; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; import com.ibrasoft.lensbridge.service.RefreshTokenService; -import com.ibrasoft.lensbridge.model.auth.RefreshToken; - -import jakarta.servlet.http.HttpServletRequest; +import com.ibrasoft.lensbridge.service.UserService; @RestController @RequestMapping("/api/auth") public class AuthController { private static final Logger log = LoggerFactory.getLogger(AuthController.class); - + @Autowired AuthenticationManager authenticationManager; @@ -73,11 +77,13 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login SecurityContextHolder.getContext().setAuthentication(authentication); - // Check if user's email is verified - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findByEmail(userDetails.getUsername()).orElse(null); + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + return ResponseEntity.status(401) + .body(new MessageResponse("User not found.")); + } - if (user != null && !user.isVerified()) { + if (!user.isVerified()) { return ResponseEntity.badRequest() .body(new MessageResponse("Please verify your email before logging in.")); } @@ -86,25 +92,25 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login loginAttemptService.recordSuccessfulAttempt(clientKey); String jwt = jwtUtils.generateJwtToken(authentication); - + // Create refresh token String deviceInfo = request.getHeader("User-Agent"); String ipAddress = getClientIpAddress(request); RefreshToken refreshToken = refreshTokenService.createRefreshToken( - userDetails.getId(), deviceInfo, ipAddress); - - log.debug("Created refresh token for user {}: {}", userDetails.getId(), refreshToken.getTokenHash()); - - List roles = userDetails.getAuthorities().stream() - .map(item -> item.getAuthority()) + user.getId(), deviceInfo, ipAddress); + + log.debug("Created refresh token for user {}: {}", user.getId(), refreshToken.getTokenHash()); + + List roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, refreshToken.getTokenHash(), - userDetails.getFirstName(), - userDetails.getLastName(), - userDetails.getId(), - userDetails.getEmail(), + user.getFirstName(), + user.getLastName(), + user.getId(), + user.getEmail(), roles)); } catch (Exception e) { // Record failed login attempt @@ -183,8 +189,7 @@ public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordReques return ResponseEntity.badRequest().body(new MessageResponse("You must be logged in to change your password.")); } - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findByEmail(userDetails.getUsername()).orElse(null); + User user = userService.findByEmail(authentication.getName()).orElse(null); if (user == null) { return ResponseEntity.badRequest().body(new MessageResponse("User not found.")); @@ -203,39 +208,41 @@ public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordReques @PostMapping("/refresh-token") public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest request) { String requestRefreshToken = request.getRefreshToken(); - + log.debug("Attempting to refresh token: {}", requestRefreshToken); try { Optional refreshTokenOpt = refreshTokenService.findByToken(requestRefreshToken); - + if (refreshTokenOpt.isEmpty()) { log.warn("Refresh token not found in database: {}", requestRefreshToken); return ResponseEntity.status(401) .body(new MessageResponse("Refresh token is not in database. Please login again.")); } - + RefreshToken refreshToken = refreshTokenOpt.get(); - log.debug("Found refresh token for user: {}", refreshToken.getUserId()); - + log.debug("Found refresh token for user: {}", refreshToken.getUser().getId()); + // Verify token is not expired or revoked RefreshToken verifiedToken = refreshTokenService.verifyExpiration(refreshToken); - + UUID userId = verifiedToken.getUserId(); User user = userService.findById(userId).orElse(null); - + if (user == null) { log.warn("User not found for refresh token: {}", userId); return ResponseEntity.status(401) .body(new MessageResponse("User not found. Please login again.")); } - + // Create new access token - UserDetailsImpl userDetails = UserDetailsImpl.build(user); + List authorities = user.getRoles().stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); Authentication auth = new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); + user.getEmail(), null, authorities); String newAccessToken = jwtUtils.generateJwtToken(auth); - + // Rotate refresh token (recommended for security) refreshTokenService.revokeRefreshToken(requestRefreshToken); RefreshToken newRefreshToken = refreshTokenService.createRefreshToken( @@ -245,7 +252,7 @@ public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest re return ResponseEntity.ok(new TokenRefreshResponse( newAccessToken, newRefreshToken.getToken())); - + } catch (RuntimeException e) { // Handle expired or revoked tokens if (e.getMessage().contains("expired") || e.getMessage().contains("revoked")) { @@ -253,7 +260,7 @@ public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest re return ResponseEntity.status(401) .body(new MessageResponse("Refresh token expired or revoked. Please login again.")); } - + // Log unexpected errors log.error("Error refreshing token: {}", e.getMessage(), e); return ResponseEntity.status(500) @@ -262,21 +269,20 @@ public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest re } @PostMapping("/logout") - public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request, + public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request, Authentication authentication) { try { // Revoke refresh token if provided if (request != null && request.getRefreshToken() != null) { refreshTokenService.revokeRefreshToken(request.getRefreshToken()); } - + // If user is authenticated, we could also revoke all their tokens if (authentication != null && authentication.isAuthenticated()) { // Optionally revoke all user tokens here if needed - // UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - // refreshTokenService.revokeAllUserTokens(userDetails.getId()); + // refreshTokenService.revokeAllUserTokens(user.getId()); } - + return ResponseEntity.ok(new MessageResponse("Logged out successfully")); } catch (Exception e) { return ResponseEntity.ok(new MessageResponse("Logged out successfully")); // Always return success for logout @@ -289,9 +295,14 @@ public ResponseEntity logoutAllDevices(Authentication authentication) { return ResponseEntity.badRequest().body(new MessageResponse("You must be logged in.")); } - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - refreshTokenService.revokeAllUserTokens(userDetails.getId()); - + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + return ResponseEntity.status(401) + .body(new MessageResponse("User not found")); + } + + refreshTokenService.revokeAllUserTokens(user.getId()); + return ResponseEntity.ok(new MessageResponse("Logged out from all devices successfully")); } @@ -303,27 +314,24 @@ public ResponseEntity validateToken(Authentication authentication) { } try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findById(userDetails.getId()).orElse(null); - + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { return ResponseEntity.status(401) .body(new MessageResponse("User not found")); } - + // Return user information if token is valid return ResponseEntity.ok(new TokenValidationResponse( true, - userDetails.getId(), - userDetails.getFirstName(), - userDetails.getLastName(), - userDetails.getEmail(), - userDetails.isVerified(), - userDetails.getAuthorities().stream() - .map(authority -> authority.getAuthority()) - .collect(Collectors.toList()) + user.getId(), + user.getFirstName(), + user.getLastName(), + user.getEmail(), + user.isVerified(), + user.getRoles().stream().collect(Collectors.toList()) )); - + } catch (Exception e) { log.error("Error validating token: {}", e.getMessage(), e); return ResponseEntity.status(401) diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 9ea9b2b..a1fc44a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -15,7 +15,6 @@ import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.auth.Role; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; @@ -30,6 +29,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -179,8 +179,7 @@ public ResponseEntity createPoster( Poster response = posterService.createPoster(createRequest, imageFile); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -194,8 +193,7 @@ public ResponseEntity updatePoster( log.info("Admin updating poster: {}", posterId); Poster response = posterService.updatePoster(posterId, updateRequest); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } @@ -209,8 +207,7 @@ public ResponseEntity updatePosterImage( log.info("Admin updating poster image: {}", posterId); Poster response = posterService.updatePosterImage(posterId, imageFile); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(response); } @@ -223,8 +220,7 @@ public ResponseEntity deletePoster( log.info("Admin deleting poster: {}", posterId); posterService.deletePoster(posterId); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_POSTER, "Poster", posterId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Poster deleted successfully")); } @@ -257,8 +253,7 @@ public ResponseEntity createEvent( log.info("Admin creating new calendar event: name={}", createRequest.getName()); Event created = boardService.createEvent(createRequest); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); return ResponseEntity.status(HttpStatus.CREATED).body(created); } @@ -272,8 +267,7 @@ public ResponseEntity updateEvent( log.info("Admin updating calendar event: {}", eventId); Event updated = boardService.updateEvent(eventId, updateRequest); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(updated); } @@ -286,8 +280,7 @@ public ResponseEntity deleteEvent( log.info("Admin deleting calendar event: {}", eventId); boardService.deleteEvent(eventId); - UserDetailsImpl user = getCurrentUser(); - auditService.logAuditEvent(user.getEmail(), AuditAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse("Calendar event deleted successfully")); } @@ -300,7 +293,8 @@ public ResponseEntity refreshSignboard() { // ==================== Helper Methods ==================== - private UserDetailsImpl getCurrentUser() { - return (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index d547a1a..3730c6c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -12,7 +12,6 @@ import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; @@ -23,6 +22,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.socket.CloseStatus; @@ -64,15 +64,15 @@ public ResponseEntity get(@PathVariable UUID deviceId) { @PostMapping("/enrollment-tokens") public ResponseEntity issueEnrollmentToken( @Valid @RequestBody IssueEnrollmentTokenRequest request) { - UserDetailsImpl admin = currentUser(); + String adminEmail = getCurrentUserEmail(); Issued issued = enrollmentTokenService.issue( request.getDisplayName(), request.getAudience(), request.getExpiresInMinutes(), - admin.getEmail() + adminEmail ); log.info("Admin {} issued enrollment token {} ({})", - admin.getEmail(), issued.token().getId(), request.getDisplayName()); + adminEmail, issued.token().getId(), request.getDisplayName()); return ResponseEntity.status(HttpStatus.CREATED).body( IssueEnrollmentTokenResponse.builder() .tokenId(issued.token().getId()) @@ -94,7 +94,7 @@ public ResponseEntity revoke(@PathVariable UUID deviceId) { d.setRevokedAt(Instant.now()); deviceRepository.save(d); sessionRegistry.closeIfPresent(deviceId, CloseStatus.POLICY_VIOLATION.withReason("device_revoked")); - log.warn("Device {} revoked by {}", deviceId, currentUser().getEmail()); + log.warn("Device {} revoked by {}", deviceId, getCurrentUserEmail()); } return ResponseEntity.ok(DeviceSummary.of(d)); } @@ -102,8 +102,7 @@ public ResponseEntity revoke(@PathVariable UUID deviceId) { @PostMapping("/{deviceId}/commands") public ResponseEntity issueCommand(@PathVariable UUID deviceId, @Valid @RequestBody IssueCommandRequest request) { - UserDetailsImpl admin = currentUser(); - CommandIssuedResponse response = commandDispatcher.issue(deviceId, admin.getEmail(), request); + CommandIssuedResponse response = commandDispatcher.issue(deviceId, getCurrentUserEmail(), request); return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); } @@ -117,7 +116,8 @@ public ResponseEntity> recentCommands(@PathVariable UUID devic return ResponseEntity.ok(commands); } - private UserDetailsImpl currentUser() { - return (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index 30e66e0..c277ef5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -4,10 +4,11 @@ import com.ibrasoft.lensbridge.dto.upload.response.*; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.DirectUploadService; import com.ibrasoft.lensbridge.service.UploadService; +import com.ibrasoft.lensbridge.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -31,6 +32,7 @@ public class FileUploadController { private final UploadService uploadService; private final DirectUploadService directUploadService; private final UploadProperties uploadProperties; + private final UserService userService; @GetMapping("/{uploadId}") @PreAuthorize("hasRole('" + Role.VERIFIED + "')") @@ -67,7 +69,11 @@ public ResponseEntity generatePresignedUploadUrl( @RequestParam String expectedSha256, Authentication authentication) { try { - UserDetailsImpl user = (UserDetailsImpl) authentication.getPrincipal(); + User user = getCurrentUser(authentication); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of("Authentication required")); + } String highestRole = getHighestRole(authentication); PresignedUploadResponse response = directUploadService.createPresignedUpload( @@ -107,7 +113,11 @@ public ResponseEntity completeDirectUpload( @RequestParam String expectedSha256, Authentication authentication) { try { - UserDetailsImpl user = (UserDetailsImpl) authentication.getPrincipal(); + User user = getCurrentUser(authentication); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of("Authentication required")); + } UploadCompletionResponse response = directUploadService.completeDirectUpload( eventId, @@ -138,7 +148,11 @@ public ResponseEntity completeDirectUpload( @PreAuthorize("hasRole('" + Role.VERIFIED + "')") public ResponseEntity getUploadLimits(Authentication authentication) { try { - UserDetailsImpl user = (UserDetailsImpl) authentication.getPrincipal(); + User user = getCurrentUser(authentication); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of("Authentication required")); + } String highestRole = getHighestRole(authentication); DataSize maxSize = uploadProperties.getMaxSizeForRole(highestRole); int dailyLimit = uploadProperties.getDailyLimitForRole(highestRole); @@ -189,4 +203,11 @@ private String getHighestRole(Authentication authentication) { return "verified"; // Default to verified } + private User getCurrentUser(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + return userService.findByEmail(authentication.getName()).orElse(null); + } + } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java index 5051950..1176fdb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java @@ -27,7 +27,6 @@ import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; @@ -51,8 +50,7 @@ public ResponseEntity getUserProfile(Authentication authentication) { } try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User user = userService.findById(userDetails.getId()).orElse(null); + User user = userService.findByEmail(authentication.getName()).orElse(null); if (user == null) { return ResponseEntity.status(404) @@ -87,8 +85,13 @@ public ResponseEntity updateUserProfile(@Valid @RequestBody UpdateProfileRequ } try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - User updatedUser = userService.updateProfile(userDetails.getId(), updateRequest); + User currentUser = userService.findByEmail(authentication.getName()).orElse(null); + if (currentUser == null) { + return ResponseEntity.status(404) + .body(new MessageResponse("User not found")); + } + + User updatedUser = userService.updateProfile(currentUser.getId(), updateRequest); UserInfoResponse response = new UserInfoResponse( updatedUser.getId(), @@ -120,8 +123,13 @@ public ResponseEntity getUserStats(Authentication authentication) { } try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - UserStatsResponse stats = uploadService.getUserStats(userDetails.getId()); + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + return ResponseEntity.status(404) + .body(new MessageResponse("User not found")); + } + + UserStatsResponse stats = uploadService.getUserStats(user.getId()); return ResponseEntity.ok(stats); @@ -141,8 +149,13 @@ public ResponseEntity getUserUploads(Pageable pageable, Authentication authen } try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - Page uploads = uploadService.getUserUploadsAsGalleryItems(userDetails.getId(), pageable); + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + return ResponseEntity.status(404) + .body(new MessageResponse("User not found")); + } + + Page uploads = uploadService.getUserUploadsAsGalleryItems(user.getId(), pageable); return ResponseEntity.ok(uploads); @@ -162,8 +175,13 @@ public ResponseEntity deleteUserUpload(@PathVariable UUID uploadId, Authentic } try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - uploadService.deleteUserUpload(uploadId, userDetails.getId()); + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + return ResponseEntity.status(404) + .body(new MessageResponse("User not found")); + } + + uploadService.deleteUserUpload(uploadId, user.getId()); return ResponseEntity.ok(new MessageResponse("Upload deleted successfully")); diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java index 0913886..4547353 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java @@ -1,11 +1,14 @@ package com.ibrasoft.lensbridge.dto.auth.response; import java.util.List; +import java.util.Set; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Data; +import com.ibrasoft.lensbridge.model.auth.Role; + @Data @AllArgsConstructor public class UserInfoResponse { @@ -14,5 +17,5 @@ public class UserInfoResponse { private String lastName; private String email; private boolean isVerified; - private List roles; + private Set roles; } diff --git a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java index 6a7221b..e6cfff6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java @@ -3,14 +3,12 @@ import java.security.Key; import java.util.Date; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; @@ -26,11 +24,8 @@ public class JwtUtils { private int jwtExpirationMs; public String generateJwtToken(Authentication authentication) { - - UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal(); - return Jwts.builder() - .setSubject((userPrincipal.getUsername())) + .setSubject(authentication.getName()) .setIssuedAt(new Date()) .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) .signWith(key(), SignatureAlgorithm.HS256) diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java deleted file mode 100644 index 4cd3b6b..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsImpl.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.ibrasoft.lensbridge.security.services; - -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.stream.Collectors; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import com.ibrasoft.lensbridge.model.auth.User; -import com.fasterxml.jackson.annotation.JsonIgnore; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class UserDetailsImpl implements UserDetails { - private static final long serialVersionUID = 1L; - - private UUID id; - - private String firstName; - - private String lastName; - - private String email; - - @JsonIgnore - private String studentNumber; - - @JsonIgnore - private String password; - - private boolean isVerified; - - private Collection authorities; - - public static UserDetailsImpl build(User user) { - List authorities = user.getRoles().stream() - .map(role -> new SimpleGrantedAuthority(role)) - .collect(Collectors.toList()); - - return new UserDetailsImpl( - user.getId(), - user.getFirstName(), - user.getLastName(), - user.getEmail(), - user.getStudentNumber(), - user.getPassword(), - user.isVerified(), - authorities); - } - - public String getUsername() { - return email; - } - - - @Override - public Collection getAuthorities() { - return authorities; - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index 7968e29..d074d84 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -1,6 +1,8 @@ package com.ibrasoft.lensbridge.security.services; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -9,6 +11,9 @@ import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import java.util.List; +import java.util.stream.Collectors; + @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired @@ -19,7 +24,15 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx User user = userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); - return UserDetailsImpl.build(user); + List authorities = user.getRoles().stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getEmail()) + .password(user.getPassword()) + .authorities(authorities) + .build(); } } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java index f0c76df..75630da 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java @@ -5,7 +5,6 @@ import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; import com.ibrasoft.lensbridge.service.agent.AgentSession; import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; @@ -76,16 +75,8 @@ void revoke_closesRegisteredAgentSession() throws Exception { } private static void setCurrentUser() { - UserDetailsImpl user = new UserDetailsImpl( - UUID.randomUUID(), - "Root", - "Admin", - "root@example.com", - "1000000000", - "password", - true, - List.of(new SimpleGrantedAuthority("ROLE_ROOT"))); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_ROOT")); SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())); + new UsernamePasswordAuthenticationToken("root@example.com", null, authorities)); } } From bb58b77f46488d3f206191e53ba7e5c43020e817 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 16:56:30 -0400 Subject: [PATCH 14/52] fixes to the stuff I just wrote --- src/main/java/com/ibrasoft/lensbridge/model/auth/User.java | 4 +--- .../java/com/ibrasoft/lensbridge/model/upload/Event.java | 2 +- .../java/com/ibrasoft/lensbridge/model/upload/Upload.java | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index e141ff0..1eea65a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -1,10 +1,9 @@ package com.ibrasoft.lensbridge.model.auth; -import java.util.List; import java.util.Set; import java.util.UUID; -import org.springframework.data.annotation.Id; +import jakarta.persistence.Id; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; @@ -15,7 +14,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Index; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java index 37192a3..94e23ed 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java @@ -4,7 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; +import jakarta.persistence.Id; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index 9bf59d4..a3d734e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -6,7 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import org.springframework.data.annotation.Id; +import jakarta.persistence.Id; import com.ibrasoft.lensbridge.model.auth.User; @@ -25,7 +25,7 @@ import java.util.UUID; @Entity -@Table(name = "devices") +@Table(name = "uploads") @Getter @Setter @NoArgsConstructor @@ -64,7 +64,7 @@ public class Upload { @Enumerated(EnumType.STRING) private UploadType contentType; - @Column(nullable = false) + @Column(nullable = true) /** * hehe we don't delete things anymore * DB is append-only, we just mark things as deleted and filter them out in queries From 604afed7abd4c101c02fea37dacc824b7ec39d14 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 17:44:04 -0400 Subject: [PATCH 15/52] smol refactors --- .../controller/AdminController.java | 4 +- .../controller/BoardAdminController.java | 17 ++++---- .../controller/EventsController.java | 12 +++--- .../controller/MusallahBoardController.java | 14 +++---- .../board/{Event.java => BoardEvent.java} | 2 +- .../lensbridge/model/board/Device.java | 7 +++- .../upload/{Event.java => MediaEvent.java} | 2 +- .../lensbridge/model/upload/Upload.java | 3 +- .../repository/sql/BoardEventRepository.java | 18 ++++----- .../repository/upload/EventsRepository.java | 6 +-- .../lensbridge/service/BoardService.java | 26 ++++++------ .../lensbridge/service/EventsService.java | 40 +++++++++---------- .../lensbridge/service/GalleryService.java | 4 +- .../lensbridge/service/UploadService.java | 8 ++-- .../service/board/BoardPayloadAssembler.java | 8 ++-- .../DailyScheduleFrameTransformer.java | 22 +++++----- .../EventListFrameTransformer.java | 22 +++++----- 17 files changed, 108 insertions(+), 107 deletions(-) rename src/main/java/com/ibrasoft/lensbridge/model/board/{Event.java => BoardEvent.java} (95%) rename src/main/java/com/ibrasoft/lensbridge/model/upload/{Event.java => MediaEvent.java} (96%) diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 87c3614..987d08c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -8,7 +8,7 @@ import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.service.EventsService; import com.ibrasoft.lensbridge.service.UploadService; @@ -78,7 +78,7 @@ public ResponseEntity createEvent(@RequestParam("eventName") String eventName (ignored) -> eventsService.createEvent(eventName, eventDate), AuditAction.CREATE_EVENT, "Event", - Event::getId, + MediaEvent::getId, "Event created successfully"); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index a1fc44a..c1845bc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -10,8 +10,8 @@ import com.ibrasoft.lensbridge.handler.SignboardHandler; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; -import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.auth.Role; @@ -24,7 +24,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -228,30 +227,30 @@ public ResponseEntity deletePoster( // ==================== Calendar Event Endpoints ==================== @GetMapping("/events") - public ResponseEntity> getAllEvents() { + public ResponseEntity> getAllEvents() { log.debug("Admin fetching all calendar events"); return ResponseEntity.ok(boardService.getAllEvents()); } @GetMapping("/events/by-audience") - public ResponseEntity> getEventsForAudience(@RequestParam Audience audience) { + public ResponseEntity> getEventsForAudience(@RequestParam Audience audience) { log.debug("Admin fetching calendar events for audience: {}", audience); return ResponseEntity.ok(boardService.getEventsForAudience(audience)); } @GetMapping("/events/{eventId}") - public ResponseEntity getEventById(@PathVariable UUID eventId) { + public ResponseEntity getEventById(@PathVariable UUID eventId) { log.debug("Admin fetching calendar event: {}", eventId); return ResponseEntity.ok(boardService.getEventById(eventId)); } @PostMapping("/events") - public ResponseEntity createEvent( + public ResponseEntity createEvent( @Valid @RequestBody CreateCalendarEventRequest createRequest, HttpServletRequest request) { log.info("Admin creating new calendar event: name={}", createRequest.getName()); - Event created = boardService.createEvent(createRequest); + BoardEvent created = boardService.createEvent(createRequest); auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_CALENDAR_EVENT, "CalendarEvent", created.getId(), request.getRemoteAddr()); @@ -259,13 +258,13 @@ public ResponseEntity createEvent( } @PatchMapping("/events/{eventId}") - public ResponseEntity updateEvent( + public ResponseEntity updateEvent( @PathVariable UUID eventId, @Valid @RequestBody UpdateCalendarEventRequest updateRequest, HttpServletRequest request) { log.info("Admin updating calendar event: {}", eventId); - Event updated = boardService.updateEvent(eventId, updateRequest); + BoardEvent updated = boardService.updateEvent(eventId, updateRequest); auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_CALENDAR_EVENT, "CalendarEvent", eventId, request.getRemoteAddr()); diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java index f4af638..e5fdbd1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.service.EventsService; import lombok.RequiredArgsConstructor; @@ -20,20 +20,20 @@ public class EventsController { private final EventsService eventsService; @GetMapping - public ResponseEntity> getAllEvents() { + public ResponseEntity> getAllEvents() { eventsService.cleanUpOldEvents(); - List events = eventsService.getAllEvents().stream() + List mediaEvents = eventsService.getAllEvents().stream() .filter(event -> event.getStatus() == EventStatus.ONGOING || (event.getStatus() == EventStatus.PAST && event.getDate().isAfter(LocalDateTime.now().minusDays(7)))) .toList(); - return ResponseEntity.ok(events); + return ResponseEntity.ok(mediaEvents); } @GetMapping("/{id}") - public ResponseEntity getEventById(@PathVariable UUID id) { - Optional event = eventsService.getEventById(id); + public ResponseEntity getEventById(@PathVariable UUID id) { + Optional event = eventsService.getEventById(id); return event.map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index 41824e8..d3b46ea 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -2,8 +2,8 @@ import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; -import com.ibrasoft.lensbridge.model.board.Event; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.service.BoardService; @@ -70,20 +70,20 @@ public ResponseEntity> getActivePosterFrames(@RequestParam // ==================== Calendar Events ==================== @GetMapping("/events") - public ResponseEntity> getUpcomingEvents(@RequestParam Audience audience) { + public ResponseEntity> getUpcomingEvents(@RequestParam Audience audience) { log.debug("Musallah board fetching upcoming events for audience: {}", audience); - List events = boardService.getUpcomingEventsForAudience(audience); - return ResponseEntity.ok(events); + List boardEvents = boardService.getUpcomingEventsForAudience(audience); + return ResponseEntity.ok(boardEvents); } @GetMapping("/events/range") - public ResponseEntity> getEventsInRange( + public ResponseEntity> getEventsInRange( @RequestParam Audience audience, @RequestParam("start") Instant rangeStart, @RequestParam("end") Instant rangeEnd) { log.debug("Musallah board fetching events for {} in range {} - {}", audience, rangeStart, rangeEnd); - List events = boardService.getEventsForAudienceInRange(audience, rangeStart, rangeEnd); - return ResponseEntity.ok(events); + List boardEvents = boardService.getEventsForAudienceInRange(audience, rangeStart, rangeEnd); + return ResponseEntity.ok(boardEvents); } // ==================== Combined Payload ==================== diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java similarity index 95% rename from src/main/java/com/ibrasoft/lensbridge/model/board/Event.java rename to src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java index 85a0ba5..d76f71a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java @@ -9,7 +9,7 @@ @Entity @Table(name = "board_events") @Data @Builder @NoArgsConstructor @AllArgsConstructor -public class Event { +public class BoardEvent { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java index 57bc445..cb9d7e8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java @@ -22,10 +22,12 @@ public class Device { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - // TODO: If you ever add multi-tenancy, actually use this + // If you ever add multi-tenancy, remove the default and make this required // Hardcoded for now because we only have one organization + @Builder.Default private String organizationId = "utmmsa"; - @NotBlank + + @Column(nullable = false) private String displayName; private Instant enrolledAt; @@ -34,6 +36,7 @@ public class Device { @OneToOne(mappedBy = "device", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private DeviceConfig config; + @Enumerated(EnumType.STRING) private Audience audience; /** Ed25519 public key (32 bytes). Set during enrollment; null before. */ diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java similarity index 96% rename from src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java rename to src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java index 94e23ed..16610cc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Event.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java @@ -22,7 +22,7 @@ @Builder @Entity @Table(name = "events") -public class Event { +public class MediaEvent { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index a3d734e..bb23757 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -1,7 +1,6 @@ package com.ibrasoft.lensbridge.model.upload; import lombok.AllArgsConstructor; -import lombok.Data; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -53,7 +52,7 @@ public class Upload { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "event_id") - private Event event; + private MediaEvent mediaEvent; private Instant createdDate; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java index 3ed22ff..5ae75a8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/BoardEventRepository.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.repository.sql; import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.Event; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -12,16 +12,16 @@ import java.util.UUID; @Repository -public interface BoardEventRepository extends JpaRepository { +public interface BoardEventRepository extends JpaRepository { - List findAllByOrderByStartTimeAsc(); + List findAllByOrderByStartTimeAsc(); - @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY e.startTime ASC") - List findByAudienceOrBoth(@Param("aud") Audience audience); + @Query("SELECT e FROM BoardEvent e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY e.startTime ASC") + List findByAudienceOrBoth(@Param("aud") Audience audience); - @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime >= :now ORDER BY e.startTime ASC") - List findUpcomingByAudienceOrBoth(@Param("aud") Audience audience, @Param("now") Instant now); + @Query("SELECT e FROM BoardEvent e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime >= :now ORDER BY e.startTime ASC") + List findUpcomingByAudienceOrBoth(@Param("aud") Audience audience, @Param("now") Instant now); - @Query("SELECT e FROM Event e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime <= :rangeEnd AND e.endTime >= :rangeStart ORDER BY e.startTime ASC") - List findOverlappingForAudienceOrBoth(@Param("aud") Audience audience, @Param("rangeStart") Instant rangeStart, @Param("rangeEnd") Instant rangeEnd); + @Query("SELECT e FROM BoardEvent e WHERE (e.audience = :aud OR e.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND e.startTime <= :rangeEnd AND e.endTime >= :rangeStart ORDER BY e.startTime ASC") + List findOverlappingForAudienceOrBoth(@Param("aud") Audience audience, @Param("rangeStart") Instant rangeStart, @Param("rangeEnd") Instant rangeEnd); } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java index 941f006..70639c0 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/EventsRepository.java @@ -3,12 +3,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import java.util.Optional; import java.util.UUID; @Repository -public interface EventsRepository extends JpaRepository { - Optional findEventById(UUID uuid); +public interface EventsRepository extends JpaRepository { + Optional findEventById(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 7f152c6..6facc38 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -155,31 +155,31 @@ public void deleteWeeklyContent(int year, int weekNumber) { // ==================== Events ==================== - public List getAllEvents() { + public List getAllEvents() { return boardEventRepository.findAllByOrderByStartTimeAsc(); } - public Event getEventById(UUID eventId) { + public BoardEvent getEventById(UUID eventId) { return boardEventRepository.findById(eventId) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Event not found with id: " + eventId))); } - public List getEventsForAudience(Audience audience) { + public List getEventsForAudience(Audience audience) { return boardEventRepository.findByAudienceOrBoth(audience); } - public List getUpcomingEventsForAudience(Audience audience) { + public List getUpcomingEventsForAudience(Audience audience) { return boardEventRepository.findUpcomingByAudienceOrBoth(audience, Instant.now()); } - public List getEventsForAudienceInRange(Audience audience, Instant rangeStart, Instant rangeEnd) { + public List getEventsForAudienceInRange(Audience audience, Instant rangeStart, Instant rangeEnd) { return boardEventRepository.findOverlappingForAudienceOrBoth(audience, rangeStart, rangeEnd); } - public Event createEvent(CreateCalendarEventRequest request) { - Event event = Event.builder() + public BoardEvent createEvent(CreateCalendarEventRequest request) { + BoardEvent boardEvent = BoardEvent.builder() .name(request.getName()) .description(request.getDescription()) .location(request.getLocation()) @@ -188,13 +188,13 @@ public Event createEvent(CreateCalendarEventRequest request) { .allDay(request.getAllDay()) .audience(request.getAudience()) .build(); - Event saved = boardEventRepository.save(event); + BoardEvent saved = boardEventRepository.save(boardEvent); log.info("Created event: id={}, name={}", saved.getId(), saved.getName()); return saved; } - public Event updateEvent(UUID eventId, UpdateCalendarEventRequest request) { - Event existing = getEventById(eventId); + public BoardEvent updateEvent(UUID eventId, UpdateCalendarEventRequest request) { + BoardEvent existing = getEventById(eventId); Patch.apply(request.getName(), existing::setName); Patch.apply(request.getDescription(), existing::setDescription); Patch.apply(request.getLocation(), existing::setLocation); @@ -202,14 +202,14 @@ public Event updateEvent(UUID eventId, UpdateCalendarEventRequest request) { Patch.apply(request.getEndTime(), existing::setEndTime); Patch.apply(request.getAllDay(), existing::setAllDay); Patch.apply(request.getAudience(), existing::setAudience); - Event saved = boardEventRepository.save(existing); + BoardEvent saved = boardEventRepository.save(existing); log.info("Updated event: id={}", eventId); return saved; } public void deleteEvent(UUID eventId) { - Event event = getEventById(eventId); - boardEventRepository.delete(event); + BoardEvent boardEvent = getEventById(eventId); + boardEventRepository.delete(boardEvent); log.info("Deleted event: id={}", eventId); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index 40d9f9b..34090a8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.dto.upload.request.CreateEventDto; -import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.repository.upload.EventsRepository; @@ -21,25 +21,25 @@ public class EventsService { private final EventsRepository eventsRepository; - public Event createEvent(CreateEventDto event) { - Event newEvent = Event.builder() + public MediaEvent createEvent(CreateEventDto event) { + MediaEvent newMediaEvent = MediaEvent.builder() .name(event.getName()) .date(event.getDate()) .build(); - return eventsRepository.save(newEvent); + return eventsRepository.save(newMediaEvent); } - public List getAllEvents() { + public List getAllEvents() { return eventsRepository.findAll(); } - public Optional getEventById(UUID id) { + public Optional getEventById(UUID id) { return eventsRepository.findById(id); } - public Event updateEvent(Event event) { - return eventsRepository.save(event); + public MediaEvent updateEvent(MediaEvent mediaEvent) { + return eventsRepository.save(mediaEvent); } public void deleteEvent(UUID id) { @@ -52,27 +52,27 @@ public void cleanUpOldEvents() { } public boolean isEventAcceptingUploads(UUID eventId) { - Optional eventOpt = eventsRepository.findById(eventId); + Optional eventOpt = eventsRepository.findById(eventId); if (eventOpt.isEmpty()) { return false; } - Event event = eventOpt.get(); + MediaEvent mediaEvent = eventOpt.get(); - return event.getStatus() == EventStatus.ONGOING || - (event.getStatus() == EventStatus.PAST && - event.getDate().isAfter(LocalDateTime.now().minusDays(7))); + return mediaEvent.getStatus() == EventStatus.ONGOING || + (mediaEvent.getStatus() == EventStatus.PAST && + mediaEvent.getDate().isAfter(LocalDateTime.now().minusDays(7))); } public void cleanUpOldEvents(LocalDateTime now) { LocalDate today = now.toLocalDate(); - List allEvents = eventsRepository.findAll(); + List allMediaEvents = eventsRepository.findAll(); - for (Event event : allEvents) { - if (event.getDate() == null) + for (MediaEvent mediaEvent : allMediaEvents) { + if (mediaEvent.getDate() == null) continue; - LocalDate eventDate = event.getDate().toLocalDate(); + LocalDate eventDate = mediaEvent.getDate().toLocalDate(); EventStatus newStatus; if (eventDate.isBefore(today)) { @@ -80,15 +80,15 @@ public void cleanUpOldEvents(LocalDateTime now) { } else if (eventDate.isAfter(today)) { newStatus = EventStatus.UPCOMING; } else { - if (!now.isBefore(event.getDate())) { + if (!now.isBefore(mediaEvent.getDate())) { newStatus = EventStatus.ONGOING; } else { newStatus = EventStatus.UPCOMING; } } - event.setStatus(newStatus); - eventsRepository.save(event); + mediaEvent.setStatus(newStatus); + eventsRepository.save(mediaEvent); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 7efd9e6..fdb3d0f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -2,7 +2,7 @@ import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.repository.upload.EventsRepository; import com.ibrasoft.lensbridge.repository.upload.UploadRepository; @@ -153,7 +153,7 @@ private String getEventName(UUID eventId) { if (eventId == null) return "General"; return eventsRepository.findEventById(eventId) - .map(Event::getName) + .map(MediaEvent::getName) .orElse("General"); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index 585433a..886f715 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -5,7 +5,7 @@ import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.upload.Event; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.model.upload.UploadType; import com.ibrasoft.lensbridge.repository.upload.UploadRepository; @@ -338,8 +338,8 @@ private GalleryItemDto convertToUserGalleryItem(Upload upload, UUID userId) { // Event information if (upload.getEventId() != null) { - Optional eventOpt = eventsService.getEventById(upload.getEventId()); - item.setEvent(eventOpt.map(Event::getName).orElse("General")); + Optional eventOpt = eventsService.getEventById(upload.getEventId()); + item.setEvent(eventOpt.map(MediaEvent::getName).orElse("General")); } else { item.setEvent("General"); } @@ -411,7 +411,7 @@ private AdminUploadDto convertToAdminUploadDto(Upload upload) { // Fetch and populate event information if (upload.getEventId() != null) { - Optional eventOpt = eventsService.getEventById(upload.getEventId()); + Optional eventOpt = eventsService.getEventById(upload.getEventId()); eventOpt.ifPresent(event -> dto.setEventName(event.getName())); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index e5eb4bf..c85a3f7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -4,7 +4,7 @@ import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Device; -import com.ibrasoft.lensbridge.model.board.Event; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; @@ -62,10 +62,10 @@ private List posterFrames(BoardContext ctx) { } private List eventFrames(BoardContext ctx) { - List events = boardService.getEventsForAudienceInRange( + List boardEvents = boardService.getEventsForAudienceInRange( ctx.getDevice().getAudience(), ctx.currentWeekStart(), ctx.currentWeekEnd()); - if (events.isEmpty()) return List.of(); - return List.of(eventListTransformer.transform(events, ctx)); + if (boardEvents.isEmpty()) return List.of(); + return List.of(eventListTransformer.transform(boardEvents, ctx)); } private List weeklyContentFrames(BoardContext ctx) { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java index db14000..62043e3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service.board.transformer; import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import com.ibrasoft.lensbridge.model.board.Event; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; @@ -17,7 +17,7 @@ * representing events for the current day. */ @Component -public class DailyScheduleFrameTransformer implements FrameTransformer> { +public class DailyScheduleFrameTransformer implements FrameTransformer> { @Override public FrameType supports() { @@ -25,8 +25,8 @@ public FrameType supports() { } @Override - public FrameDefinition transform(List events, BoardContext ctx) { - List eventViews = events.stream() + public FrameDefinition transform(List boardEvents, BoardContext ctx) { + List eventViews = boardEvents.stream() .map(this::toEventView) .collect(Collectors.toList()); @@ -44,14 +44,14 @@ public FrameDefinition transform(List events, BoardContext ctx) { .build(); } - private EventView toEventView(Event event) { + private EventView toEventView(BoardEvent boardEvent) { return EventView.builder() - .name(event.getName()) - .description(event.getDescription()) - .location(event.getLocation()) - .startTime(event.getStartTime()) - .endTime(event.getEndTime()) - .allDay(event.getAllDay()) + .name(boardEvent.getName()) + .description(boardEvent.getDescription()) + .location(boardEvent.getLocation()) + .startTime(boardEvent.getStartTime()) + .endTime(boardEvent.getEndTime()) + .allDay(boardEvent.getAllDay()) .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java index 375d723..d0a9df2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java @@ -1,7 +1,7 @@ package com.ibrasoft.lensbridge.service.board.transformer; import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import com.ibrasoft.lensbridge.model.board.Event; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; @@ -17,7 +17,7 @@ * representing events for the current week. */ @Component -public class EventListFrameTransformer implements FrameTransformer> { +public class EventListFrameTransformer implements FrameTransformer> { @Override public FrameType supports() { @@ -25,8 +25,8 @@ public FrameType supports() { } @Override - public FrameDefinition transform(List events, BoardContext ctx) { - List eventViews = events.stream() + public FrameDefinition transform(List boardEvents, BoardContext ctx) { + List eventViews = boardEvents.stream() .map(this::toEventView) .collect(Collectors.toList()); @@ -44,14 +44,14 @@ public FrameDefinition transform(List events, BoardContext ctx) { .build(); } - private EventView toEventView(Event event) { + private EventView toEventView(BoardEvent boardEvent) { return EventView.builder() - .name(event.getName()) - .description(event.getDescription()) - .location(event.getLocation()) - .startTime(event.getStartTime()) - .endTime(event.getEndTime()) - .allDay(event.getAllDay()) + .name(boardEvent.getName()) + .description(boardEvent.getDescription()) + .location(boardEvent.getLocation()) + .startTime(boardEvent.getStartTime()) + .endTime(boardEvent.getEndTime()) + .allDay(boardEvent.getAllDay()) .build(); } } From 111314b04c066e7a78b69b17feb32b081149a1ba Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 19:49:05 -0400 Subject: [PATCH 16/52] working towards full rewrite --- .../lensbridge/LensBridgeApplication.java | 4 - .../lensbridge/config/WebSocketConfig.java | 4 +- .../dto/upload/response/AdminUploadDto.java | 4 +- .../lensbridge/model/audit/AuditEvent.java | 10 +- .../lensbridge/model/auth/RefreshToken.java | 15 ++- .../ibrasoft/lensbridge/model/auth/Role.java | 10 +- .../ibrasoft/lensbridge/model/auth/User.java | 37 ++++++- .../lensbridge/model/board/BoardEvent.java | 16 ++- .../lensbridge/model/board/Device.java | 8 +- .../lensbridge/model/board/Poster.java | 12 +++ .../lensbridge/model/upload/MediaEvent.java | 15 ++- .../lensbridge/model/upload/Upload.java | 2 +- .../audit/AuditEventRepository.java | 19 ++++ .../auth/RefreshTokenRepository.java | 20 ++-- .../auth/VerificationTokenRepository.java | 5 +- .../repository/upload/UploadRepository.java | 24 ++++- .../services/UserDetailsServiceImpl.java | 2 +- .../lensbridge/service/AdminAuditService.java | 26 ++++- .../lensbridge/service/EventsService.java | 18 ++-- .../lensbridge/service/GalleryService.java | 14 +-- .../service/RefreshTokenService.java | 43 ++++---- .../lensbridge/service/UploadService.java | 99 +++++++++++-------- .../lensbridge/service/UserService.java | 8 +- .../AdminControllerIntegrationTest.java | 8 +- 24 files changed, 301 insertions(+), 122 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java index b9bdc6f..c9901a4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java +++ b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java @@ -2,14 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableAsync -@EnableJpaRepositories(basePackages = "com.ibrasoft.lensbridge.repository.sql") -@EnableMongoRepositories(basePackages = "com.ibrasoft.lensbridge.repository.mongo") public class LensBridgeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java index a642f6f..faa3200 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package com.ibrasoft.lensbridge.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; @@ -42,6 +43,7 @@ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { } @Bean + @ConditionalOnProperty(name = "lensbridge.websocket.container-customizer.enabled", havingValue = "true") public ServletServerContainerFactoryBean webSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); @@ -51,4 +53,4 @@ public ServletServerContainerFactoryBean webSocketContainer() { return container; } -} \ No newline at end of file +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java index 34965ae..675f984 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/AdminUploadDto.java @@ -4,7 +4,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.UUID; import com.ibrasoft.lensbridge.model.upload.UploadType; @@ -32,7 +32,7 @@ public class AdminUploadDto { private UUID eventId; private String eventName; - private LocalDateTime createdDate; + private Instant createdDate; private boolean approved; private boolean featured; private boolean isAnon; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java index 5e9c189..e73a294 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java @@ -3,13 +3,8 @@ import lombok.Builder; import lombok.Data; -import org.hibernate.annotations.Collate; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - import com.ibrasoft.lensbridge.model.auth.User; -import jakarta.annotation.Generated; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -17,15 +12,20 @@ import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; import java.time.Instant; import java.util.UUID; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor @Entity @Table(name = "audit_events") public class AuditEvent { diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java index 5887fe0..1fbf2c5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RefreshToken.java @@ -1,11 +1,10 @@ package com.ibrasoft.lensbridge.model.auth; -import org.springframework.data.annotation.Id; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -49,4 +48,16 @@ public class RefreshToken { @Column(nullable = false) private boolean revoked; + + public String getToken() { + return tokenHash; + } + + public UUID getUserId() { + return user != null ? user.getId() : null; + } + + public boolean isExpired() { + return expiryDate != null && expiryDate.isBefore(Instant.now()); + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java index 098158e..b0e949b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java @@ -12,11 +12,11 @@ public enum Role { ROLE_ROOT("ROLE_ROOT"); // Static constants for use in @PreAuthorize annotations - public static final String USER = "ROLE_USER"; - public static final String MODERATOR = "ROLE_MODERATOR"; - public static final String ADMIN = "ROLE_ADMIN"; - public static final String VERIFIED = "ROLE_VERIFIED"; - public static final String ROOT = "ROLE_ROOT"; + public static final String USER = "USER"; + public static final String MODERATOR = "MODERATOR"; + public static final String ADMIN = "ADMIN"; + public static final String VERIFIED = "VERIFIED"; + public static final String ROOT = "ROOT"; private final String authority; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index 1eea65a..f8170f3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.model.auth; +import java.util.HashSet; import java.util.Set; import java.util.UUID; @@ -23,8 +24,10 @@ import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @AllArgsConstructor +@NoArgsConstructor @Data @Entity @Table(name = "users", indexes = { @@ -54,12 +57,44 @@ public class User { @NotBlank @JsonIgnore + @Column(nullable = false) private String passwordHash; @ElementCollection(targetClass = Role.class) @Enumerated(EnumType.STRING) @CollectionTable(name = "user_roles") - private Set roles; + @Column(name = "role", nullable = false) + private Set roles = new HashSet<>(); private boolean verified; + + // Transitional field kept while auth services are rewritten to VerificationToken. + @JsonIgnore + private String verificationToken; + + public User(String firstName, String lastName, String studentNumber, String email, String passwordHash) { + this.firstName = firstName; + this.lastName = lastName; + this.studentNumber = studentNumber; + this.email = email; + this.passwordHash = passwordHash; + this.roles = new HashSet<>(); + } + + public String getPassword() { + return passwordHash; + } + + public void setPassword(String passwordHash) { + this.passwordHash = passwordHash; + } + + public void addRole(Role role) { + if (roles == null) roles = new HashSet<>(); + roles.add(role); + } + + public boolean hasRole(Role role) { + return roles != null && roles.contains(role); + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java index d76f71a..24e8b0c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardEvent.java @@ -13,12 +13,26 @@ public class BoardEvent { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + + @Column(nullable = false) private String name; + + @Column(columnDefinition = "TEXT") private String description; + private String location; + + @Column(nullable = false) private Instant startTime; + + @Column(nullable = false) private Instant endTime; - private Boolean allDay; + + @Builder.Default + @Column(nullable = false) + private Boolean allDay = false; + @Enumerated(EnumType.STRING) + @Column(nullable = false) private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java index cb9d7e8..f81a294 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java @@ -2,7 +2,6 @@ import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,7 +23,8 @@ public class Device { // If you ever add multi-tenancy, remove the default and make this required // Hardcoded for now because we only have one organization - @Builder.Default + @Builder.Default + @Column(nullable = false) private String organizationId = "utmmsa"; @Column(nullable = false) @@ -37,6 +37,7 @@ public class Device { private DeviceConfig config; @Enumerated(EnumType.STRING) + @Column(nullable = false) private Audience audience; /** Ed25519 public key (32 bytes). Set during enrollment; null before. */ @@ -52,7 +53,8 @@ public class Device { @PrePersist private void prePersist() { - enrolledAt = Instant.now(); + if (enrolledAt == null) enrolledAt = Instant.now(); + if (organizationId == null) organizationId = "utmmsa"; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java index a217cc9..15adb58 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java @@ -12,11 +12,23 @@ public class Poster { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + + @Column(nullable = false) private String title; + + @Column(nullable = false) private String image; + + @Column(nullable = false) private int duration; + + @Column(nullable = false) private Instant startTime; + + @Column(nullable = false) private Instant endTime; + @Enumerated(EnumType.STRING) + @Column(nullable = false) private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java index 16610cc..3a452ef 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java @@ -11,7 +11,9 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; +import jakarta.persistence.Column; import jakarta.persistence.Table; +import jakarta.persistence.PrePersist; import java.time.Instant; import java.util.UUID; @@ -26,9 +28,20 @@ public class MediaEvent { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; + + @Column(nullable = false) private String name; + + @Column(nullable = false) private Instant date; + @Builder.Default @Enumerated(EnumType.STRING) - private EventStatus status; + @Column(nullable = false) + private EventStatus status = EventStatus.UPCOMING; + + @PrePersist + private void prePersist() { + if (status == null) status = EventStatus.UPCOMING; + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index bb23757..922533e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -65,7 +65,7 @@ public class Upload { @Column(nullable = true) /** - * hehe we don't delete things anymore + * We do not delete things anymore * DB is append-only, we just mark things as deleted and filter them out in queries * deletedAt = null => not deleted */ diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java index 25a0b2a..6aecd38 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/audit/AuditEventRepository.java @@ -1,12 +1,31 @@ package com.ibrasoft.lensbridge.repository.audit; import com.ibrasoft.lensbridge.model.audit.AuditEvent; +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.Instant; +import java.util.List; import java.util.UUID; @Repository public interface AuditEventRepository extends JpaRepository { + Page findAllByOrderByTimestampDesc(Pageable pageable); + + List findByAdminIdOrderByTimestampDesc(UUID adminId); + + List findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc(AuditEntityType entityType, UUID entityId); + + Page findByActionOrderByTimestampDesc(AuditAction action, Pageable pageable); + + Page findByTimestampBetweenOrderByTimestampDesc(Instant start, Instant end, Pageable pageable); + + long countByAdminId(UUID adminId); + + long countByAction(AuditAction action); } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java index 21d4dbc..cd686ea 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/RefreshTokenRepository.java @@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -13,21 +13,21 @@ @Repository public interface RefreshTokenRepository extends JpaRepository { - Optional findByToken(String token); + Optional findByTokenHash(String tokenHash); - List findByUserId(UUID userId); + List findByUser_Id(UUID userId); - List findByUserIdAndRevokedFalse(UUID userId); + List findByUser_IdAndRevokedFalse(UUID userId); - void deleteByUserId(UUID userId); + void deleteByUser_Id(UUID userId); - void deleteByToken(String token); + void deleteByTokenHash(String tokenHash); - void deleteByExpiryDateBefore(LocalDateTime dateTime); + void deleteByExpiryDateBefore(Instant dateTime); - void deleteByUserIdAndRevokedTrue(UUID userId); + void deleteByUser_IdAndRevokedTrue(UUID userId); - long countByUserIdAndRevokedFalse(UUID userId); + long countByUser_IdAndRevokedFalse(UUID userId); - List findByRevokedTrueAndCreatedDateBefore(LocalDateTime minusDays); + List findByRevokedTrueAndCreatedDateBefore(Instant minusDays); } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java index 4ca29d4..58b812e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java @@ -3,6 +3,8 @@ import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import com.ibrasoft.lensbridge.model.auth.User; @@ -10,5 +12,6 @@ @Repository public interface VerificationTokenRepository extends JpaRepository { - User findUserByToken(String tokenHash); + @Query("select v.user from VerificationToken v where v.tokenHash = :tokenHash") + User findUserByToken(@Param("tokenHash") String tokenHash); } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java index 3ecaa5b..02ecb2f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java @@ -3,11 +3,13 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; import com.ibrasoft.lensbridge.model.upload.Upload; +import java.time.Instant; import java.util.UUID; @Repository @@ -16,4 +18,24 @@ public interface UploadRepository extends JpaRepository { * Recall that deletedAt = null => not deleted. Therefore, this method will only return uploads that are not marked as deleted. */ Page findByDeletedAtIsNull(Pageable pageable); + + Page findByApprovedTrueAndDeletedAtIsNull(Pageable pageable); + + Page findByMediaEventAndApprovedTrueAndDeletedAtIsNull(MediaEvent mediaEvent, Pageable pageable); + + Page findByMediaEventAndDeletedAtIsNull(MediaEvent mediaEvent, Pageable pageable); + + Page findByApprovedAndDeletedAtIsNull(boolean approved, Pageable pageable); + + Page findByFeaturedAndDeletedAtIsNull(boolean featured, Pageable pageable); + + Page findByUploadedByAndDeletedAtIsNull(User uploadedBy, Pageable pageable); + + long countByUploadedByAndDeletedAtIsNull(User uploadedBy); + + long countByUploadedByAndApprovedAndDeletedAtIsNull(User uploadedBy, boolean approved); + + long countByUploadedByAndFeaturedAndDeletedAtIsNull(User uploadedBy, boolean featured); + + long countByUploadedByAndCreatedDateBetweenAndDeletedAtIsNull(User uploadedBy, Instant start, Instant end); } diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index d074d84..6a73494 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -25,7 +25,7 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); List authorities = user.getRoles().stream() - .map(SimpleGrantedAuthority::new) + .map(role -> new SimpleGrantedAuthority(role.getAuthority())) .collect(Collectors.toList()); return org.springframework.security.core.userdetails.User diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index fa302da..6bb0e5e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -1,8 +1,10 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; import com.ibrasoft.lensbridge.model.audit.AuditEvent; import com.ibrasoft.lensbridge.repository.audit.AuditEventRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,6 +22,7 @@ public class AdminAuditService { private final AuditEventRepository auditEventRepository; + private final UserRepository userRepository; public AuditEvent logAuditEvent(AuditEvent event) { try { @@ -34,7 +37,14 @@ public AuditEvent logAuditEvent(AuditEvent event) { } public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, String IPAddress) { - AuditEvent event = AuditEvent.builder().adminEmail(adminEmail).action(action).entityType(entityType).entityId(entityId).timestamp(Instant.now()).ipAddress(IPAddress).build(); + AuditEvent event = AuditEvent.builder() + .admin(userRepository.findByEmail(adminEmail).orElse(null)) + .action(action) + .targetEntityType(toEntityType(entityType)) + .targetEntityId(entityId) + .timestamp(Instant.now()) + .ipAddress(IPAddress) + .build(); return logAuditEvent(event); } @@ -47,7 +57,7 @@ public List getAuditEventsByAdmin(UUID adminId) { } public List getAuditEventsByEntity(String entityType, UUID entityId) { - return auditEventRepository.findByEntityTypeAndEntityIdOrderByTimestampDesc(entityType, entityId); + return auditEventRepository.findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc(toEntityType(entityType), entityId); } public Page getAuditEventsByAction(AuditAction action, Pageable pageable) { @@ -59,7 +69,7 @@ public Page getAuditEventsByDateRange(Instant start, Instant end, Pa } public Page getFailedOperations(Pageable pageable) { - return auditEventRepository.findFailedOperationsOrderByTimestampDesc(pageable); + return Page.empty(pageable); } // Statistics methods @@ -70,4 +80,14 @@ public long getOperationCountByAdmin(UUID adminId) { public long getOperationCountByAction(AuditAction action) { return auditEventRepository.countByAction(action); } + + private AuditEntityType toEntityType(String entityType) { + if (entityType == null) return AuditEntityType.EVENT; + return switch (entityType.toLowerCase()) { + case "user" -> AuditEntityType.USER; + case "upload" -> AuditEntityType.UPLOAD; + case "poster", "calendarevent", "boardevent", "musallah board" -> AuditEntityType.MUSALLAH_BOARD; + default -> AuditEntityType.EVENT; + }; + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index 34090a8..7e81b3e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -9,8 +9,9 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -34,6 +35,10 @@ public List getAllEvents() { return eventsRepository.findAll(); } + public MediaEvent createEvent(String eventName, Instant eventDate) { + return createEvent(CreateEventDto.builder().name(eventName).date(eventDate).build()); + } + public Optional getEventById(UUID id) { return eventsRepository.findById(id); } @@ -48,7 +53,7 @@ public void deleteEvent(UUID id) { @Scheduled(cron = "0 0 0 * * ?") // Runs daily at midnight public void cleanUpOldEvents() { - this.cleanUpOldEvents(LocalDateTime.now()); + this.cleanUpOldEvents(Instant.now()); } public boolean isEventAcceptingUploads(UUID eventId) { @@ -60,19 +65,20 @@ public boolean isEventAcceptingUploads(UUID eventId) { return mediaEvent.getStatus() == EventStatus.ONGOING || (mediaEvent.getStatus() == EventStatus.PAST && - mediaEvent.getDate().isAfter(LocalDateTime.now().minusDays(7))); + mediaEvent.getDate().isAfter(Instant.now().minus(java.time.Duration.ofDays(7)))); } - public void cleanUpOldEvents(LocalDateTime now) { - LocalDate today = now.toLocalDate(); + public void cleanUpOldEvents(Instant now) { + ZoneId zone = ZoneId.systemDefault(); + LocalDate today = LocalDate.ofInstant(now, zone); List allMediaEvents = eventsRepository.findAll(); for (MediaEvent mediaEvent : allMediaEvents) { if (mediaEvent.getDate() == null) continue; - LocalDate eventDate = mediaEvent.getDate().toLocalDate(); + LocalDate eventDate = LocalDate.ofInstant(mediaEvent.getDate(), zone); EventStatus newStatus; if (eventDate.isBefore(today)) { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index fdb3d0f..101e760 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import java.time.format.DateTimeFormatter; +import java.time.ZoneId; import java.util.UUID; @Service @@ -31,7 +32,7 @@ public class GalleryService { * Only approved content is returned with time-limited signed URLs. */ public Page getAllApprovedGalleryItems(Pageable pageable) { - Page uploads = uploadRepository.findByApprovedTrue(pageable); + Page uploads = uploadRepository.findByApprovedTrueAndDeletedAtIsNull(pageable); return uploads.map(this::convertToPublicGalleryItem); // Use secure URLs for public } @@ -40,7 +41,7 @@ public Page getAllApprovedGalleryItems(Pageable pageable) { * This method should only be called by admin users. */ public Page getAllGalleryItems(Pageable pageable) { - Page uploads = uploadRepository.findAll(pageable); + Page uploads = uploadRepository.findByDeletedAtIsNull(pageable); return uploads.map(this::convertToAdminGalleryItem); // Admin can see all with secure URLs } @@ -49,7 +50,9 @@ public Page getAllGalleryItems(Pageable pageable) { */ public Page getGalleryItemsByEvent(UUID eventId, Pageable pageable) { // For public event galleries, only return approved content - Page uploads = uploadRepository.findByEventId(eventId, pageable); + MediaEvent mediaEvent = eventsRepository.findById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + Page uploads = uploadRepository.findByMediaEventAndApprovedTrueAndDeletedAtIsNull(mediaEvent, pageable); return uploads.map(this::convertToPublicGalleryItem); } @@ -78,8 +81,7 @@ private GalleryItemDto convertToAdminGalleryItem(Upload upload) { */ private GalleryItemDto convertToGalleryItem(Upload upload, boolean isAdmin) { GalleryItemDto item = new GalleryItemDto(); - User uploader = userService.findById(upload.getUploadedBy()) - .orElse(null); + User uploader = upload.getUploadedBy(); // Basic info item.setId(upload.getUuid().toString()); @@ -159,7 +161,7 @@ private String getEventName(UUID eventId) { private String formatDate(Upload upload) { if (upload.getCreatedDate() != null) { - return upload.getCreatedDate().format(DateTimeFormatter.ISO_LOCAL_DATE); + return DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.systemDefault()).format(upload.getCreatedDate()); } return "Unknown"; } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java index 275fff8..7044fbb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java @@ -2,6 +2,7 @@ import com.ibrasoft.lensbridge.model.auth.RefreshToken; import com.ibrasoft.lensbridge.repository.auth.RefreshTokenRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -11,7 +12,8 @@ import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; -import java.time.LocalDateTime; +import java.time.Duration; +import java.time.Instant; import java.util.Base64; import java.util.List; import java.util.Optional; @@ -23,6 +25,7 @@ public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; @Value("${lensbridge.app.refreshTokenExpirationMs:604800000}") // 7 days default private long refreshTokenDurationMs; @@ -33,7 +36,7 @@ public class RefreshTokenService { private final SecureRandom secureRandom = new SecureRandom(); public void invalidateAllRefreshTokensForUser(UUID userId) { - List tokens = refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + List tokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); tokens.forEach(token -> token.setRevoked(true)); refreshTokenRepository.saveAll(tokens); } @@ -47,29 +50,27 @@ public RefreshToken createRefreshToken(UUID userId, String deviceInfo, String ip deleteExpiredTokensByUser(userId); // Check if user has too many active tokens - long activeTokenCount = refreshTokenRepository.countByUserIdAndRevokedFalse(userId); + long activeTokenCount = refreshTokenRepository.countByUser_IdAndRevokedFalse(userId); if (activeTokenCount >= maxRefreshTokensPerUser) { // Revoke oldest token - List userTokens = refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + List userTokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); if (!userTokens.isEmpty()) { RefreshToken oldestToken = userTokens.stream() .min((t1, t2) -> t1.getCreatedDate().compareTo(t2.getCreatedDate())) .orElse(null); if (oldestToken != null) { - revokeRefreshToken(oldestToken.getToken()); + revokeRefreshToken(oldestToken.getTokenHash()); } } } + Instant now = Instant.now(); RefreshToken refreshToken = RefreshToken.builder() - .id(UUID.randomUUID()) - .token(generateRefreshToken()) - .userId(userId) - .expiryDate(LocalDateTime.now().plusSeconds(refreshTokenDurationMs / 1000)) - .createdDate(LocalDateTime.now()) - .lastUsedDate(LocalDateTime.now()) - .deviceInfo(deviceInfo) - .ipAddress(ipAddress) + .tokenHash(generateRefreshToken()) + .user(userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found"))) + .expiryDate(now.plusSeconds(refreshTokenDurationMs / 1000)) + .createdDate(now) + .lastUsedDate(now) .revoked(false) .build(); @@ -80,7 +81,7 @@ public RefreshToken createRefreshToken(UUID userId, String deviceInfo, String ip * Find refresh token by token string */ public Optional findByToken(String token) { - return refreshTokenRepository.findByToken(token); + return refreshTokenRepository.findByTokenHash(token); } /** @@ -93,7 +94,7 @@ public RefreshToken verifyExpiration(RefreshToken token) { } // Update last used date - token.setLastUsedDate(LocalDateTime.now()); + token.setLastUsedDate(Instant.now()); return refreshTokenRepository.save(token); } @@ -102,7 +103,7 @@ public RefreshToken verifyExpiration(RefreshToken token) { */ @Transactional public void revokeRefreshToken(String token) { - refreshTokenRepository.findByToken(token).ifPresent(refreshToken -> { + refreshTokenRepository.findByTokenHash(token).ifPresent(refreshToken -> { refreshToken.setRevoked(true); refreshTokenRepository.save(refreshToken); }); @@ -113,7 +114,7 @@ public void revokeRefreshToken(String token) { */ @Transactional public void revokeAllUserTokens(UUID userId) { - List userTokens = refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + List userTokens = refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); userTokens.forEach(token -> token.setRevoked(true)); refreshTokenRepository.saveAll(userTokens); } @@ -123,7 +124,7 @@ public void revokeAllUserTokens(UUID userId) { */ @Transactional public void deleteExpiredTokensByUser(UUID userId) { - List userTokens = refreshTokenRepository.findByUserId(userId); + List userTokens = refreshTokenRepository.findByUser_Id(userId); List expiredTokens = userTokens.stream() .filter(token -> token.isExpired() || token.isRevoked()) .toList(); @@ -137,7 +138,7 @@ public void deleteExpiredTokensByUser(UUID userId) { * Get all active refresh tokens for a user (for security dashboard) */ public List getActiveTokensForUser(UUID userId) { - return refreshTokenRepository.findByUserIdAndRevokedFalse(userId); + return refreshTokenRepository.findByUser_IdAndRevokedFalse(userId); } /** @@ -156,12 +157,12 @@ private String generateRefreshToken() { @Transactional public void cleanupExpiredTokens() { try { - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); refreshTokenRepository.deleteByExpiryDateBefore(now); // Clean up revoked tokens (older than 7 days) List oldRevokedTokens = refreshTokenRepository - .findByRevokedTrueAndCreatedDateBefore(now.minusDays(7)); + .findByRevokedTrueAndCreatedDateBefore(now.minus(Duration.ofDays(7))); if (!oldRevokedTokens.isEmpty()) { refreshTokenRepository.deleteAll(oldRevokedTokens); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index 886f715..38bcb57 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -16,8 +16,9 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Optional; import java.util.UUID; @@ -94,11 +95,13 @@ public void unapproveUpload(UUID uploadId) { */ public long countUploadsToday(UUID userId) { LocalDate today = LocalDate.now(); - LocalDateTime startOfDay = today.atStartOfDay(); - LocalDateTime endOfDay = today.atTime(23, 59, 59); + Instant startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); - return uploadRepository.countByUploadedByAndCreatedDateBetween( - userId, + return uploadRepository.countByUploadedByAndCreatedDateBetweenAndDeletedAtIsNull( + user, startOfDay, endOfDay); } @@ -128,25 +131,27 @@ public Upload createDirectUpload(String objectKey, String fileName, String conte uploadType = UploadType.IMAGE; // Default fallback } - // Create Upload entity - UUID uuid = UUID.randomUUID(); - Upload upload = new Upload( - uuid, - fileName, - objectKey, - null, // thumbnailUrl - will be set async by ThumbnailService - description, - instagramHandle, - uploadedBy, - eventId, - LocalDateTime.now(), - defaultApproved, - defaultFeatured, - anon, - uploadType); + User user = userService.findById(uploadedBy) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + + Upload upload = new Upload(); + upload.setFileName(fileName); + upload.setFileUrl(objectKey); + upload.setThumbnailUrl(null); + upload.setUploadDescription(description); + upload.setInstagramHandle(instagramHandle); + upload.setUploadedBy(user); + upload.setMediaEvent(mediaEvent); + upload.setCreatedDate(Instant.now()); + upload.setApproved(defaultApproved); + upload.setFeatured(defaultFeatured); + upload.setAnon(anon); + upload.setContentType(uploadType); uploadRepository.save(upload); - log.info("Created direct upload record: {} for object: {}", uuid, objectKey); + log.info("Created direct upload record: {} for object: {}", upload.getUuid(), objectKey); return upload; } catch (Exception e) { @@ -156,11 +161,13 @@ public Upload createDirectUpload(String objectKey, String fileName, String conte } public Page getAllUploads(Pageable pageable) { - return uploadRepository.findAll(pageable); + return uploadRepository.findByDeletedAtIsNull(pageable); } public Page getUploadsByEvent(UUID eventId, Pageable pageable) { - return uploadRepository.findByEventId(eventId, pageable); + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable); } public Optional getUploadById(UUID id) { @@ -191,7 +198,10 @@ public void deleteUpload(UUID id) { // Continue with database deletion even if R2 deletion fails } } - uploadRepository.deleteById(id); + uploadOpt.ifPresent(upload -> { + upload.setDeletedAt(Instant.now()); + uploadRepository.save(upload); + }); } /** @@ -208,7 +218,7 @@ public void deleteUserUpload(UUID uploadId, UUID userId) { Upload upload = uploadOpt.get(); // Verify ownership - if (!upload.getUploadedBy().equals(userId)) { + if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { throw new SecurityException("You can only delete your own uploads"); } @@ -228,8 +238,9 @@ public void deleteUserUpload(UUID uploadId, UUID userId) { // Continue with database deletion even if R2 deletion fails } - // Delete the upload from database - uploadRepository.deleteById(uploadId); + upload.setDeletedAt(Instant.now()); + userService.findById(userId).ifPresent(upload::setDeletedBy); + uploadRepository.save(upload); log.info("Upload {} deleted successfully by user {}", uploadId, userId); } @@ -238,7 +249,7 @@ public void deleteUserUpload(UUID uploadId, UUID userId) { * This method fetches uploads and includes the uploader's name and details. */ public Page getAllUploadsForAdmin(Pageable pageable) { - Page uploads = uploadRepository.findAll(pageable); + Page uploads = uploadRepository.findByDeletedAtIsNull(pageable); return uploads.map(this::convertToAdminUploadDto); } @@ -246,7 +257,7 @@ public Page getAllUploadsForAdmin(Pageable pageable) { * Get uploads by approval status with user information for admin interface. */ public Page getUploadsByApprovalStatus(boolean approved, Pageable pageable) { - Page uploads = uploadRepository.findByApproved(approved, pageable); + Page uploads = uploadRepository.findByApprovedAndDeletedAtIsNull(approved, pageable); return uploads.map(this::convertToAdminUploadDto); } @@ -254,21 +265,25 @@ public Page getUploadsByApprovalStatus(boolean approved, Pageabl * Get uploads by featured status with user information for admin interface. */ public Page getUploadsByFeaturedStatus(boolean featured, Pageable pageable) { - Page uploads = uploadRepository.findByFeatured(featured, pageable); + Page uploads = uploadRepository.findByFeaturedAndDeletedAtIsNull(featured, pageable); return uploads.map(this::convertToAdminUploadDto); } public Page getUploadsByUploadedBy(UUID userId, Pageable pageable) { - return uploadRepository.findByUploadedBy(userId, pageable); + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable); } /** * Get user upload statistics */ public UserStatsResponse getUserStats(UUID userId) { - long totalUploads = uploadRepository.countByUploadedBy(userId); - long approvedUploads = uploadRepository.countByUploadedByAndApproved(userId, true); - long featuredUploads = uploadRepository.countByUploadedByAndFeatured(userId, true); + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + long totalUploads = uploadRepository.countByUploadedByAndDeletedAtIsNull(user); + long approvedUploads = uploadRepository.countByUploadedByAndApprovedAndDeletedAtIsNull(user, true); + long featuredUploads = uploadRepository.countByUploadedByAndFeaturedAndDeletedAtIsNull(user, true); long pendingUploads = totalUploads - approvedUploads; return new UserStatsResponse( @@ -283,7 +298,9 @@ public UserStatsResponse getUserStats(UUID userId) { * regardless of approval status) */ public Page getUserUploadsAsGalleryItems(UUID userId, Pageable pageable) { - Page uploads = uploadRepository.findByUploadedBy(userId, pageable); + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + Page uploads = uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable); return uploads.map(upload -> convertToUserGalleryItem(upload, userId)); } @@ -293,7 +310,7 @@ public Page getUserUploadsAsGalleryItems(UUID userId, Pageable p */ private GalleryItemDto convertToUserGalleryItem(Upload upload, UUID userId) { // Verify the user owns this upload - if (!upload.getUploadedBy().equals(userId)) { + if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { throw new SecurityException("User can only access their own uploads"); } @@ -346,7 +363,9 @@ private GalleryItemDto convertToUserGalleryItem(Upload upload, UUID userId) { // Format date if (upload.getCreatedDate() != null) { - item.setDate(upload.getCreatedDate().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE)); + item.setDate(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneId.systemDefault()) + .format(upload.getCreatedDate())); } else { item.setDate("Unknown"); } @@ -368,7 +387,7 @@ private AdminUploadDto convertToAdminUploadDto(Upload upload) { dto.setFileUrl(upload.getFileUrl()); // Keep original URL for internal reference dto.setUploadDescription(upload.getUploadDescription()); dto.setInstagramHandle(upload.getInstagramHandle()); - dto.setUploadedBy(upload.getUploadedBy()); + dto.setUploadedBy(upload.getUploadedBy() != null ? upload.getUploadedBy().getId() : null); dto.setEventId(upload.getEventId()); dto.setCreatedDate(upload.getCreatedDate()); dto.setApproved(upload.isApproved()); @@ -400,7 +419,7 @@ private AdminUploadDto convertToAdminUploadDto(Upload upload) { dto.setThumbnailUrl(null); } - Optional userOpt = userService.findById(upload.getUploadedBy()); + Optional userOpt = Optional.ofNullable(upload.getUploadedBy()); if (userOpt.isPresent()) { User user = userOpt.get(); dto.setUploaderFirstName(user.getFirstName()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index bc7b40f..f6836c8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -1,6 +1,6 @@ package com.ibrasoft.lensbridge.service; -import java.util.List; +import java.util.HashSet; import java.util.Optional; import java.util.UUID; @@ -107,7 +107,7 @@ public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { (signUpRequest.getPassword() == null) ? null : passwordEncoder.encode(signUpRequest.getPassword()) ); - user.setRoles(List.of()); + user.setRoles(new HashSet<>()); user.setVerified(false); // Generate verification token and send email @@ -200,7 +200,7 @@ public User removeRole(UUID userId, Role role) { throw new IllegalArgumentException("User does not have this role: " + role.getAuthority()); } - user.getRoles().remove(role.getAuthority()); + user.getRoles().remove(role); User savedUser = saveUser(user); log.info("Role {} removed successfully from user: {}", role.getAuthority(), user.getEmail()); @@ -314,4 +314,4 @@ public User updateProfile(UUID userId, UpdateProfileRequest updateRequest) { log.info("Profile updated successfully for user: {}", user.getEmail()); return savedUser; } -} \ No newline at end of file +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java index 10d4691..8f11863 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java @@ -14,6 +14,7 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = AdminController.class) @@ -44,9 +45,9 @@ void accessDeniedForUnauthenticatedUsers() throws Exception { } @Test - @WithMockUser(roles = {Role.ADMIN}) + @WithMockUser(roles = {Role.USER}) void accessDeniedForUserRole() throws Exception { - mockMvc.perform(get(adminBaseURL + "/create-event")) + mockMvc.perform(post(adminBaseURL + "/create-event").with(csrf())) .andExpect(status().isForbidden()); } @@ -55,9 +56,10 @@ void accessDeniedForUserRole() throws Exception { authorities = {"ROLE_ADMIN"}) void accessGrantedForAdminRole() throws Exception { mockMvc.perform(post(adminBaseURL + "/create-event") + .with(csrf()) .param("eventName", "Test Event") .param("eventDate", "2024-12-01T10:00:00") - .param("status", "ACTIVE")) + .param("status", "UPCOMING")) .andExpect(status().isOk()); } } From bc8fe3f4053e24026deff7fce81390cf1793cf5a Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 10 May 2026 19:54:29 -0400 Subject: [PATCH 17/52] codex plan tentatively being added --- docs/CONTROLLER_SERVICE_REWRITE.md | 368 +++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/CONTROLLER_SERVICE_REWRITE.md diff --git a/docs/CONTROLLER_SERVICE_REWRITE.md b/docs/CONTROLLER_SERVICE_REWRITE.md new file mode 100644 index 0000000..aa70c7f --- /dev/null +++ b/docs/CONTROLLER_SERVICE_REWRITE.md @@ -0,0 +1,368 @@ +# Controller and Service Rewrite Plan + +## Summary + +Rewrite controllers and services around clearer ownership boundaries. Controllers should stay thin and HTTP-focused; services should own business rules, persistence orchestration, authorization checks, audit logging, storage integration, and entity-to-DTO mapping. + +The rewrite should proceed by vertical slice, keeping the project compiling after each slice. + +## Target Structure + +- Controllers handle routing, validation annotations, security annotations, request DTOs, response DTOs, and status codes only. +- Services own business rules, persistence orchestration, ownership checks, audit calls, storage calls, and mapping. +- Repositories remain data-only and expose query methods that match entity relationships. +- Errors flow through `ApiResponseException` and `GlobalExceptionHandler`; remove broad controller `try/catch` blocks. +- Use constructor injection everywhere; remove field `@Autowired`. +- Do not return JPA entities from public/user/admin media APIs. Board admin endpoints may temporarily return entities during the rewrite. + +## Controllers + +### `AuthController` + +Auth endpoints only: + +- `POST /api/auth/signup` +- `POST /api/auth/signin` +- `POST /api/auth/refresh-token` +- `POST /api/auth/logout` +- `POST /api/auth/logout-all-devices` +- `POST /api/auth/verify-email` +- `POST /api/auth/forgot-password` +- `GET /api/auth/validate-reset-token` +- `POST /api/auth/reset-password` +- `POST /api/auth/change-password` +- `GET|POST /api/auth/validate-token` + +Delegate all auth flow logic to `AuthService`. + +### `UserController` + +Current-user endpoints only: + +- `GET /api/user/profile` +- `PATCH /api/user/profile` +- `GET /api/user/stats` +- `GET /api/user/uploads` +- `DELETE /api/user/uploads/{uploadId}` + +Delegate profile operations to `UserAccountService` and upload reads/deletes to `UploadQueryService` / `UploadService`. + +### `MediaEventController` + +Public read-only event API for uploads/gallery: + +- `GET /api/events` -> list events accepting uploads. +- `GET /api/events/{eventId}` -> get one media event. + +Delegate to `MediaEventService`. + +### `UploadController` + +Verified-user upload flow only: + +- `GET /api/upload/limits` +- `GET /api/upload/{uploadId}` +- `POST /api/upload/{eventId}/direct/presign` +- `POST /api/upload/{eventId}/direct/complete` + +Use request DTOs for presign and complete instead of many request params. + +### `GalleryController` + +Public gallery only: + +- `GET /api/gallery` +- `GET /api/gallery/event/{eventId}` + +Delegate to `UploadQueryService`. + +### `AdminMediaController` + +Admin moderation and media-event management: + +- List uploads by filter: all, pending, approved, featured. +- Approve/unapprove uploads. +- Feature/unfeature uploads. +- Soft-delete uploads. +- Create/update media events. +- List users, verify users, add/remove roles. + +This replaces the media/user portions of the current `AdminController`. + +### `BoardAdminController` + +Board content only: + +- Board config CRUD. +- Weekly content CRUD. +- Poster CRUD and image replacement. +- `BoardEvent` CRUD. +- Board refresh trigger. + +Do not mix media uploads or user administration into this controller. + +### `DeviceAdminController` + +Device fleet only: + +- List/get devices. +- Issue enrollment token. +- Revoke device. +- Issue command. +- List recent commands. + +Do not inject repositories directly; delegate to `DeviceService` and `DeviceCommandService`. + +### `MusallahBoardController` + +Board display read API only. + +Preferred endpoint: + +- `GET /api/musallah/payload?deviceId=...` + +Keep granular config/content/poster/event endpoints only while the frontend still needs them. + +### `AgentEnrollmentController` + +Keep focused on unauthenticated enrollment token exchange: + +- `POST /api/agent/enroll` + +Delegate enrollment logic to `DeviceEnrollmentService`. + +## Services + +### `AuthService` + +Owns login/session orchestration: + +- `signIn(LoginRequest, RequestContext)` +- `refresh(TokenRefreshRequest, RequestContext)` +- `logout(rawRefreshToken)` +- `logoutAll(userId)` +- `validateCurrentUser(Authentication)` + +It authenticates credentials, checks verification, records login attempts, creates JWTs, and issues/rotates refresh tokens. + +### `UserAccountService` + +Owns user lifecycle and role invariants: + +- `createUser(SignupRequest, boolean sendEmail)` +- `verifyEmail(token)` +- `verifyDirectly(userId)` +- `updateProfile(userId, UpdateProfileRequest)` +- `changePassword(userId, ChangePasswordRequest)` +- `addRole(userId, Role)` +- `removeRole(userId, Role)` + +It owns password hashing, verification-token lifecycle, email triggers, and role mutation rules. + +### `RefreshTokenService` + +Stores only token hashes: + +- `issue(User, deviceInfo, ip)` +- `rotate(rawToken, deviceInfo, ip)` +- `revoke(rawToken)` +- `revokeAll(userId)` +- `pruneExpired()` + +Return raw refresh tokens only at creation/rotation boundaries; persist hashes only. + +### `MediaEventService` + +Owns `MediaEvent` lifecycle and upload window policy: + +- `create(request)` +- `update(id, request)` +- `get(id)` +- `listAll()` +- `listAcceptingUploads()` +- `isAcceptingUploads(id)` +- `refreshStatuses(now)` + +Keep `BoardEvent` and `MediaEvent` separate for this rewrite. + +### `UploadService` + +Owns upload lifecycle mutations: + +- `createDirectUpload(...)` +- `approve(id)` +- `unapprove(id)` +- `feature(id)` +- `unfeature(id)` +- `softDeleteByAdmin(id, adminId)` +- `softDeleteByOwner(id, userId)` + +Use `User` and `MediaEvent` relationships directly, not raw `uploadedBy` or `eventId` UUID fields. + +### `DirectUploadService` + +Owns direct-upload orchestration: + +- `presign(eventId, userId, role, request)` +- `complete(eventId, userId, request)` + +It validates event acceptance, file type, size, daily limits, storage existence, and thumbnail trigger behavior. + +### `UploadQueryService` + +Owns upload reads and response mapping: + +- `adminUploads(filter, pageable)` +- `userUploads(userId, pageable)` +- `publicGallery(pageable)` +- `publicGalleryByEvent(eventId, pageable)` +- `userStats(userId)` + +It maps `Upload` to `AdminUploadDto`, `GalleryItemDto`, and user upload views. + +### `BoardConfigService` + +Owns board config operations: + +- `get(deviceId)` +- `save(deviceId, request)` +- `patch(deviceId, request)` +- `list()` + +### `WeeklyContentService` + +Owns weekly board content: + +- `get(year, week)` +- `getCurrent(zone)` +- `listByYear(year)` +- `save(year, week, request)` +- `delete(year, week)` + +### `BoardEventService` + +Owns `BoardEvent` lifecycle and display queries: + +- `create(request)` +- `patch(id, request)` +- `delete(id)` +- `get(id)` +- `listAll()` +- `listForAudience(audience)` +- `listInRange(audience, start, end)` + +Validate `endTime > startTime`. + +### `PosterService` + +Owns poster metadata and image storage: + +- `create(request, image)` +- `patch(id, request)` +- `replaceImage(id, image)` +- `delete(id)` +- `listAll()` +- `listActiveForAudience(audience, now)` + +Remove frame-building from this service; frame conversion belongs to board assembly. + +### `BoardPayloadAssembler` + +Single owner of display payload creation. + +It should: + +- Load device and config. +- Build `BoardContext`. +- Fetch active posters, board events, and weekly content. +- Delegate frame creation to transformers. +- Return `MusallahBoardPayload`. + +### `DeviceService` + +Owns device fleet operations: + +- `list()` +- `get(id)` +- `revoke(id, adminEmail)` +- `recordHeartbeat(id, frame, ip)` + +Keeps `DeviceAdminController` away from repositories. + +### `DeviceCommandService` + +Wrap or rename `CommandDispatcher`: + +- `issue(deviceId, adminEmail, request)` +- `listRecent(deviceId)` +- `flushPending(deviceId, session)` +- `onAck(frame, session)` +- `onProgress(frame, session)` +- `onResult(frame, session)` + +Keep command state transitions in one service. + +### `AuditService` + +Owns audit logging: + +- `record(actor, action, targetType, targetId, requestContext)` + +Controllers do not build `AuditEvent`; admin services audit after successful mutations. + +## DTO and Wiring Rules + +- Add compact request DTOs for upload presign, upload complete, media-event create/update, and admin upload filters. +- Add response mappers for: + - `UserInfoResponse` + - `AdminUploadDto` + - `GalleryItemDto` + - `DeviceSummary` + - `CommandView` + - board payload response types +- Add `CurrentUserService` or `RequestContextResolver` exposing: + - current user id + - email + - roles + - ip address + - user agent +- Store storage object keys on entities; generate signed URLs in query/mapping services. +- Public gallery returns only approved, non-deleted uploads. +- Admin upload queries may include pending/unapproved uploads. +- Upload deletion is soft-delete via `deletedAt` and `deletedBy`. + +## Rewrite Order + +1. Fix repository methods to match current relationships (`Upload.uploadedBy`, `Upload.mediaEvent`, soft-delete filters). +2. Add request-context/current-user helper. +3. Split auth logic into `AuthService` and align `RefreshTokenService`. +4. Split media upload reads into `UploadQueryService`. +5. Rewrite upload mutation methods around soft-delete and relationships. +6. Replace `AdminController` with `AdminMediaController`. +7. Split board services into config, weekly content, board event, poster, and payload assembly. +8. Move device repository access behind `DeviceService`. +9. Remove entity responses from public/user/admin media controllers. +10. Delete old duplicated controller/service methods after replacements compile. + +## Test Plan + +- Controller tests verify routes, auth annotations, status codes, and DTO shape. +- Service tests cover: + - signup, verification, password reset, token rotation + - media event status and upload acceptance windows + - presign/complete validation and daily limits + - upload approve, feature, soft-delete, and owner checks + - public gallery excluding unapproved/deleted uploads + - board event range and audience filtering + - poster date validation and image replacement + - device revoke and command state transitions +- Mapper tests cover admin upload, gallery item, user info, command summary, and device summary. +- Keep compile green after each vertical slice. + +## Assumptions + +- `BoardEvent` and `MediaEvent` remain separate for this rewrite. +- Upload deletion is soft-delete, not storage deletion, for normal user/admin delete flows. +- Storage cleanup can be a later maintenance job. +- Board payload assembly remains the preferred pattern for board display output. +- Board admin entity responses are temporary and should be replaced after the core rewrite stabilizes. From d3e7e75c2aac1fe8002d9715b40f54d536e66cda Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 12 May 2026 16:46:26 -0400 Subject: [PATCH 18/52] Final auth model changes --- .../ibrasoft/lensbridge/model/auth/Role.java | 64 ++++--------------- .../ibrasoft/lensbridge/model/auth/User.java | 11 ++-- .../model/auth/VerificationToken.java | 8 ++- .../repository/auth/UserRepository.java | 2 - .../auth/VerificationTokenRepository.java | 8 +++ 5 files changed, 30 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java index b0e949b..d14554f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java @@ -1,62 +1,20 @@ package com.ibrasoft.lensbridge.model.auth; -/** - * Enum representing user roles in the system. - * This enum provides type safety and prevents typos in role names. - */ -public enum Role { - ROLE_USER("ROLE_USER"), - ROLE_MODERATOR("ROLE_MODERATOR"), - ROLE_ADMIN("ROLE_ADMIN"), - ROLE_VERIFIED("ROLE_VERIFIED"), - ROLE_ROOT("ROLE_ROOT"); +import org.springframework.security.core.GrantedAuthority; - // Static constants for use in @PreAuthorize annotations - public static final String USER = "USER"; - public static final String MODERATOR = "MODERATOR"; - public static final String ADMIN = "ADMIN"; - public static final String VERIFIED = "VERIFIED"; - public static final String ROOT = "ROOT"; +public enum Role implements GrantedAuthority { + USER, ADMIN, ROOT; - private final String authority; - - Role(String authority) { - this.authority = authority; - } - - /** - * Gets the authority string used by Spring Security. - * @return the authority string (e.g., "ROLE_USER") - */ - public String getAuthority() { - return authority; - } - - /** - * Gets the role name without the ROLE_ prefix. - * @return the role name (e.g., "USER") - */ - public String getRoleName() { - return authority.substring(5); - } - - /** - * Converts a string to a Role enum. - * @param authority the authority string - * @return the corresponding Role enum - * @throws IllegalArgumentException if the authority is not recognized - */ - public static Role fromAuthority(String authority) { - for (Role role : values()) { - if (role.authority.equals(authority)) { - return role; - } - } - throw new IllegalArgumentException("Unknown role authority: " + authority); + // String constants for @PreAuthorize("hasRole('" + Role.Authority.ADMIN + "')") + // Spring Security's hasRole() prepends ROLE_ automatically, so these are just the name. + public interface Authority { + String USER = "USER"; + String ADMIN = "ADMIN"; + String ROOT = "ROOT"; } @Override - public String toString() { - return authority; + public String getAuthority() { + return "ROLE_" + name(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index f8170f3..e91daf2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.model.auth; +import java.time.Instant; import java.util.HashSet; import java.util.Set; import java.util.UUID; @@ -66,11 +67,7 @@ public class User { @Column(name = "role", nullable = false) private Set roles = new HashSet<>(); - private boolean verified; - - // Transitional field kept while auth services are rewritten to VerificationToken. - @JsonIgnore - private String verificationToken; + private Instant verifiedAt; public User(String firstName, String lastName, String studentNumber, String email, String passwordHash) { this.firstName = firstName; @@ -97,4 +94,8 @@ public void addRole(Role role) { public boolean hasRole(Role role) { return roles != null && roles.contains(role); } + + public boolean isVerified() { + return verifiedAt != null; + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java index f5ea0e3..cd0aef8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java @@ -9,14 +9,16 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.NoArgsConstructor; @Entity @Builder @AllArgsConstructor +@NoArgsConstructor @Table(name = "verification_tokens") public class VerificationToken { @Id @@ -26,12 +28,12 @@ public class VerificationToken { @Column(nullable = false, unique = true) private String tokenHash; - @OneToOne + @ManyToOne @JoinColumn(name = "user_id", nullable = false) private User user; private Instant createdAt; private Instant expiresAt; - private boolean used; + private Instant usedAt; } diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java index 344a6b8..24dcc51 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java @@ -13,8 +13,6 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - Optional findByVerificationToken(String verificationToken); - Boolean existsByEmail(String email); Boolean existsByStudentNumber(String studentNumber); diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java index 58b812e..6c6265c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.repository.auth; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,6 +13,13 @@ @Repository public interface VerificationTokenRepository extends JpaRepository { + @Query("select v.user from VerificationToken v where v.tokenHash = :tokenHash") User findUserByToken(@Param("tokenHash") String tokenHash); + + @Query("select v from VerificationToken v where v.tokenHash = :tokenHash and v.usedAt is null and v.expiresAt > current_timestamp") + Optional findValidToken(@Param("tokenHash") String tokenHash); + + @Query("select v from VerificationToken v where v.user = :user order by v.createdAt desc limit 1") + Optional findLatestByUser(@Param("user") User user); } From 525cb2089ef916f3ace529ed39d930f90df7d8a4 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 00:06:09 -0400 Subject: [PATCH 19/52] finalized service and controller rewrites, still needs tests --- pom.xml | 19 +- .../lensbridge/config/AuditIndexConfig.java | 41 -- .../config/AuthTokenProperties.java | 15 + .../config/ImageProcessingProperties.java | 15 + .../lensbridge/config/RateLimitingFilter.java | 2 +- .../lensbridge/config/UploadProperties.java | 96 +-- .../ibrasoft/lensbridge/config/WebConfig.java | 10 + .../controller/AdminController.java | 159 ++--- .../lensbridge/controller/AuthController.java | 281 +++------ .../controller/BoardAdminController.java | 2 +- .../controller/DeviceAdminController.java | 2 +- .../controller/EventsController.java | 18 +- .../controller/FileUploadController.java | 197 ++---- .../controller/GalleryController.java | 18 +- .../lensbridge/controller/UserController.java | 198 ++---- .../DailyLimitExceededException.java | 15 + .../EventNotAcceptingUploadsException.java | 15 + .../FileSizeLimitExceededException.java | 15 + .../exception/GlobalExceptionHandler.java | 77 ++- .../InvalidContentTypeException.java | 13 + .../exception/RefreshTokenException.java | 16 + .../exception/VideoProcessingException.java | 6 - .../exception/VideoTooLongException.java | 11 - .../ibrasoft/lensbridge/model/auth/User.java | 6 +- .../model/auth/VerificationToken.java | 16 +- .../lensbridge/model/upload/MediaEvent.java | 2 + .../lensbridge/model/upload/Upload.java | 4 +- .../auth/VerificationTokenRepository.java | 5 +- .../repository/upload/UploadRepository.java | 3 + .../lensbridge/security/CurrentUser.java | 13 + .../security/CurrentUserArgumentResolver.java | 41 ++ .../lensbridge/security/jwt/JwtUtils.java | 4 +- .../services/UserDetailsServiceImpl.java | 8 +- .../service/DirectUploadService.java | 203 ------- .../lensbridge/service/EventsService.java | 49 +- .../lensbridge/service/GalleryService.java | 145 ++--- .../service/ImageProcessingService.java | 73 +++ .../service/MediaConversionService.java | 123 ---- .../lensbridge/service/PosterService.java | 4 +- .../lensbridge/service/R2StorageService.java | 562 ++++++++---------- .../service/RefreshTokenService.java | 13 +- .../service/ThumbnailBackfillService.java | 97 --- .../lensbridge/service/ThumbnailService.java | 164 ----- .../service/UploadLimitsService.java | 75 +++ .../lensbridge/service/UploadService.java | 405 ++++--------- .../service/UploadWorkflowService.java | 131 ++++ .../lensbridge/service/UserService.java | 282 +++------ .../service/VerificationTokenService.java | 96 +++ .../AdminControllerIntegrationTest.java | 2 +- 49 files changed, 1388 insertions(+), 2379 deletions(-) delete mode 100644 src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java diff --git a/pom.xml b/pom.xml index 761379c..0bb21ff 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.2 + 3.5.14 com.ibrasoft @@ -31,10 +31,6 @@ 21 - - org.springframework.boot - spring-boot-starter-data-mongodb - org.springframework.boot spring-boot-starter-web @@ -89,8 +85,6 @@ caffeine 3.1.8 - - software.amazon.awssdk s3 @@ -163,9 +157,14 @@ - org.postgresql - postgresql - runtime + org.xerial + sqlite-jdbc + 3.47.1.0 + + + + org.hibernate.orm + hibernate-community-dialects diff --git a/src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java deleted file mode 100644 index bf73179..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/config/AuditIndexConfig.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.ibrasoft.lensbridge.config; - -import jakarta.annotation.PostConstruct; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.index.Index; -import org.springframework.data.mongodb.core.index.IndexOperations; - -@Configuration -public class AuditIndexConfig { - - private final MongoTemplate mongoTemplate; - - public AuditIndexConfig(MongoTemplate mongoTemplate) { - this.mongoTemplate = mongoTemplate; - } - - @PostConstruct - public void createAuditIndexes() { - IndexOperations indexOps = mongoTemplate.indexOps("audit_events"); - - // Index on timestamp for chronological queries - indexOps.createIndex(new Index().on("timestamp", Sort.Direction.DESC)); - - // Index on adminId for admin-specific queries - indexOps.createIndex(new Index().on("adminId", Sort.Direction.ASC)); - - // Index on entityType and entityId for entity-specific queries - indexOps.createIndex(new Index().on("entityType", Sort.Direction.ASC).on("entityId", Sort.Direction.ASC)); - - // Index on action for action-specific queries - indexOps.createIndex(new Index().on("action", Sort.Direction.ASC)); - - // Index on result for failed operation queries - indexOps.createIndex(new Index().on("result", Sort.Direction.ASC)); - - // Compound index for date range queries with admin filter - indexOps.createIndex(new Index().on("timestamp", Sort.Direction.DESC).on("adminId", Sort.Direction.ASC)); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java new file mode 100644 index 0000000..aced262 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/AuthTokenProperties.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "auth.token") +@Getter +@Setter +public class AuthTokenProperties { + private long verificationExpirationMs; + private long passwordResetExpirationMs; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java new file mode 100644 index 0000000..8305918 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "image-processing") +@Data +public class ImageProcessingProperties { + private int thumbnailWidth; + private int thumbnailHeight; + private double thumbnailQuality; + private String thumbnailFolder; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java b/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java index 4b2dfb7..dc18568 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/RateLimitingFilter.java @@ -35,7 +35,7 @@ public class RateLimitingFilter extends OncePerRequestFilter { @Value("${ratelimit.duration.minutes:1}") private int durationMinutes; - @Value("${ratelimit.exemptRoles:ROLE_ROOT,ROLE_ADMIN}") + @Value("${ratelimit.exemptRoles:ROOT,ADMIN}") private List exemptRoles; private final Cache bucketCache = Caffeine.newBuilder() diff --git a/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java index dd7e481..38bdbf5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/UploadProperties.java @@ -11,85 +11,41 @@ import java.util.List; import java.util.Map; -/** - * Configuration properties for file uploads. - */ @Component @ConfigurationProperties(prefix = "uploads") @Data public class UploadProperties { - - /** - * Maximum video duration in seconds - */ - private int videoMaxduration = 240; - - /** - * Global maximum file size (fallback) - */ + @DataSizeUnit(DataUnit.BYTES) - private DataSize globalMaxSize = DataSize.ofGigabytes(1); - - /** - * Per-role maximum file sizes - */ + private DataSize globalMaxSize; + private Map maxSize = new HashMap<>(); - - /** - * Allowed file types (MIME types) - */ - private List allowedFileTypes = List.of( -// "video/mp4", "video/quicktime", "video/x-matroska", "video/x-msvideo", - "image/jpeg", "image/png", "image/heic" - ); - - /** - * Per-role daily upload limits - */ + + private List allowedFileTypes; + private Map dailyLimit = new HashMap<>(); - - /** - * Global daily upload limit (fallback) - */ - private int globalDailyLimit = 25; - - /** - * Get the maximum allowed file size for a specific role. - * Falls back to verified limit if role not found. - */ + + private int globalDailyLimit; + public DataSize getMaxSizeForRole(String role) { - String roleKey = role; - if (roleKey.startsWith("ROLE_")) { - roleKey = roleKey.substring(5).toLowerCase(); - } - - DataSize roleLimit = maxSize.get(roleKey); - if (roleLimit != null) { - return roleLimit; - } - - // Fallback to verified limit, then global limit - DataSize verifiedLimit = maxSize.get("verified"); - return verifiedLimit != null ? verifiedLimit : globalMaxSize; + String key = normalizeRoleKey(role); + DataSize roleLimit = maxSize.get(key); + if (roleLimit != null) return roleLimit; + DataSize userLimit = maxSize.get("user"); + return userLimit != null ? userLimit : globalMaxSize; } - - /** - * Get the daily upload limit for a specific role. - * Falls back to verified limit if role not found. - */ + public int getDailyLimitForRole(String role) { - String roleKey = role; - if (roleKey.startsWith("ROLE_")) { - roleKey = roleKey.substring(5).toLowerCase(); - } - - Integer roleLimit = dailyLimit.get(roleKey); - if (roleLimit != null) { - return roleLimit; - } - - // Fallback to verified limit, then global limit - Integer verifiedLimit = dailyLimit.get("verified"); - return verifiedLimit != null ? verifiedLimit : globalDailyLimit; + String key = normalizeRoleKey(role); + Integer roleLimit = dailyLimit.get(key); + if (roleLimit != null) return roleLimit; + Integer userLimit = dailyLimit.get("user"); + return userLimit != null ? userLimit : globalDailyLimit; + } + + private String normalizeRoleKey(String role) { + if (role == null) return "user"; + String key = role.startsWith("ROLE_") ? role.substring(5) : role; + return key.toLowerCase(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java index 5448b35..b84f5b6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java @@ -2,12 +2,14 @@ import java.util.List; +import com.ibrasoft.lensbridge.security.CurrentUserArgumentResolver; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.lang.NonNull; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -23,6 +25,9 @@ public class WebConfig implements WebMvcConfigurer { @Autowired private RateLimitingFilter rateLimitingFilter; + @Autowired + private CurrentUserArgumentResolver currentUserArgumentResolver; + @Bean public FilterRegistrationBean rateLimitingFilterRegistration() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); @@ -32,6 +37,11 @@ public FilterRegistrationBean rateLimitingFilterRegistration return registrationBean; } + @Override + public void addArgumentResolvers(@NonNull List resolvers) { + resolvers.add(currentUserArgumentResolver); + } + @Override public void addCorsMappings(@NonNull CorsRegistry registry) { registry.addMapping("/api/**") diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 987d08c..3ab3da5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -19,7 +19,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; @@ -27,7 +26,6 @@ import org.springframework.web.bind.annotation.*; import java.time.Instant; -import java.time.LocalDateTime; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; @@ -36,7 +34,7 @@ @RestController @RequestMapping("/api/admin") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.ADMIN + "')") +@PreAuthorize("hasRole('" + Role.Authority.ADMIN + "')") @Slf4j public class AdminController { @@ -63,17 +61,15 @@ private ResponseEntity executeCreationAction(T input, HttpServletReque String successMessage) { R createdEntity = serviceAction.apply(input); UUID entityId = idExtractor.apply(createdEntity); - this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, entityType, entityId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } @PostMapping("/create-event") public ResponseEntity createEvent(@RequestParam("eventName") String eventName, - @RequestParam(value = "eventDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime eventDate, + @RequestParam(value = "eventDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant eventDate, @RequestParam(value = "status", required = false) EventStatus status, HttpServletRequest request) { - return executeCreationAction(null, request, (ignored) -> eventsService.createEvent(eventName, eventDate), AuditAction.CREATE_EVENT, @@ -110,92 +106,56 @@ public ResponseEntity unfeatureUpload(@PathVariable UUID uploadId, HttpServle // Data Retrieval Operations @GetMapping("/uploads") - public ResponseEntity getAllUploads(Pageable pageable) { - log.debug("Admin retrieving all uploads with user information, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getAllUploadsForAdmin(pageable); - return ResponseEntity.ok(uploads); + public ResponseEntity> getAllUploads(Pageable pageable) { + return ResponseEntity.ok(uploadService.getAllUploadsForAdmin(pageable)); } @GetMapping("/uploads/pending") - public ResponseEntity getPendingUploads(Pageable pageable) { - log.debug("Admin retrieving pending uploads, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getUploadsByApprovalStatus(false, pageable); - return ResponseEntity.ok(uploads); + public ResponseEntity> getPendingUploads(Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByApprovalStatus(false, pageable)); } @GetMapping("/uploads/approved") - public ResponseEntity getApprovedUploads(Pageable pageable) { - try { - log.debug("Admin retrieving approved uploads, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getUploadsByApprovalStatus(true, pageable); - return ResponseEntity.ok(uploads); - } catch (Exception e) { - log.error("Error retrieving approved uploads: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve approved uploads: " + e.getMessage())); - } + public ResponseEntity> getApprovedUploads(Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByApprovalStatus(true, pageable)); } @GetMapping("/uploads/featured") - public ResponseEntity getFeaturedUploads(Pageable pageable) { - try { - log.debug("Admin retrieving featured uploads, page: {}", pageable.getPageNumber()); - Page uploads = uploadService.getUploadsByFeaturedStatus(true, pageable); - return ResponseEntity.ok(uploads); - } catch (Exception e) { - log.error("Error retrieving featured uploads: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve featured uploads: " + e.getMessage())); - } + public ResponseEntity> getFeaturedUploads(Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByFeaturedStatus(true, pageable)); } @GetMapping("/events") public ResponseEntity getAllEvents() { - try { - log.debug("Admin retrieving all events"); - return ResponseEntity.ok(eventsService.getAllEvents()); - } catch (Exception e) { - log.error("Error retrieving events: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve events: " + e.getMessage())); - } + return ResponseEntity.ok(eventsService.getAllEvents()); } @GetMapping("/users") - @PreAuthorize("hasRole('" + Role.ROOT + "')") + @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") public ResponseEntity getAllUsers(Pageable pageable) { - try { - log.debug("Admin retrieving all users, page: {}", pageable.getPageNumber()); - return ResponseEntity.ok(userService.getAllUsers(pageable)); - } catch (Exception e) { - log.error("Error retrieving users: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve users: " + e.getMessage())); - } + return ResponseEntity.ok(userService.getAllUsers(pageable)); } @PostMapping("/user/{userId}/add-role") - @PreAuthorize("hasRole('" + Role.ROOT + "')") + @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, - (id) -> { - log.debug("Admin adding role {} to user: {}", role, id); - userService.addRole(id, role); - }, + (id) -> userService.addRole(id, role), AuditAction.ADD_USER_ROLE, "Role added successfully to user: " + userId); } @PostMapping("/user/{userId}/remove-role") - @PreAuthorize("hasRole('" + Role.ROOT + "')") + @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, - (id) -> { - log.debug("Admin removing role {} from user: {}", role, id); - userService.removeRole(id, role); - }, + (id) -> userService.removeRole(id, role), AuditAction.REMOVE_USER_ROLE, "Role removed successfully from user: " + userId); } @PostMapping("/user/verify") - @PreAuthorize("hasRole('" + Role.ROOT + "')") + @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") public ResponseEntity verifyUser(@RequestBody Map payload, HttpServletRequest request) { return executeUserAction(payload.get("userId"), request, userService::verifyDirectly, AuditAction.VERIFY_USER, @@ -203,11 +163,10 @@ public ResponseEntity verifyUser(@RequestBody Map payload, Http } @PostMapping("/user/create") - @PreAuthorize("hasRole('" + Role.ROOT + "')") + @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, HttpServletRequest request) { return executeCreationAction(signUpRequest, request, (signupRequest) -> { - log.debug("Admin creating user with email: {}", signupRequest.getEmail()); User newUser = userService.createUser(signupRequest, false); userService.verifyDirectly(newUser.getId()); userService.requestPasswordReset(newUser.getEmail()); @@ -220,90 +179,44 @@ public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, Ht } @GetMapping("/roles") - @PreAuthorize("hasRole('" + Role.ROOT + "')") + @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") public ResponseEntity getAvailableRoles() { - try { - log.debug("Admin retrieving available roles"); - return ResponseEntity.ok(Role.values()); - } catch (Exception e) { - log.error("Error retrieving roles: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve roles: " + e.getMessage())); - } + return ResponseEntity.ok(Role.values()); } // Audit Reporting Endpoints @GetMapping("/audit") - public ResponseEntity getAuditEvents(Pageable pageable) { - try { - log.debug("Admin retrieving audit events, page: {}", pageable.getPageNumber()); - Page auditEvents = auditService.getAllAuditEvents(pageable); - return ResponseEntity.ok(auditEvents); - } catch (Exception e) { - log.error("Error retrieving audit events: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve audit events: " + e.getMessage())); - } + public ResponseEntity> getAuditEvents(Pageable pageable) { + return ResponseEntity.ok(auditService.getAllAuditEvents(pageable)); } @GetMapping("/audit/failed") - public ResponseEntity getFailedOperations(Pageable pageable) { - try { - log.debug("Admin retrieving failed operations"); - Page failedOps = auditService.getFailedOperations(pageable); - return ResponseEntity.ok(failedOps); - } catch (Exception e) { - log.error("Error retrieving failed operations: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve failed operations: " + e.getMessage())); - } - } - - private String getCurrentUserEmail() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return authentication != null ? authentication.getName() : "unknown"; + public ResponseEntity> getFailedOperations(Pageable pageable) { + return ResponseEntity.ok(auditService.getFailedOperations(pageable)); } @GetMapping("/audit/upload/{uploadId}") public ResponseEntity getUploadAuditHistory(@PathVariable UUID uploadId) { - try { - log.debug("Admin retrieving audit history for upload: {}", uploadId); - return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId)); - } catch (Exception e) { - log.error("Error retrieving upload audit history: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve upload audit history: " + e.getMessage())); - } + return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId)); } @GetMapping("/audit/action/{action}") - public ResponseEntity getAuditEventsByAction(@PathVariable AuditAction action, Pageable pageable) { - try { - log.debug("Admin retrieving audit events for action: {}", action); - Page events = auditService.getAuditEventsByAction(action, pageable); - return ResponseEntity.ok(events); - } catch (Exception e) { - log.error("Error retrieving audit events by action: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve audit events: " + e.getMessage())); - } + public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, Pageable pageable) { + return ResponseEntity.ok(auditService.getAuditEventsByAction(action, pageable)); } @GetMapping("/audit/daterange") - public ResponseEntity getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, Pageable pageable) { - try { - log.debug("Admin retrieving audit events between {} and {}", start, end); - Page events = auditService.getAuditEventsByDateRange(start, end, pageable); - return ResponseEntity.ok(events); - } catch (Exception e) { - log.error("Error retrieving audit events by date range: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve audit events: " + e.getMessage())); - } + public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, Pageable pageable) { + return ResponseEntity.ok(auditService.getAuditEventsByDateRange(start, end, pageable)); } @GetMapping("/audit/actions") public ResponseEntity getAvailableActions() { - try { - log.debug("Admin retrieving available audit actions"); - return ResponseEntity.ok(AuditAction.values()); - } catch (Exception e) { - log.error("Error retrieving available actions: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve available actions: " + e.getMessage())); - } + return ResponseEntity.ok(AuditAction.values()); + } + + private String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null ? authentication.getName() : "unknown"; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index a2dc922..57aac9f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -1,29 +1,24 @@ package com.ibrasoft.lensbridge.controller; +import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; import com.ibrasoft.lensbridge.dto.auth.request.LoginRequest; @@ -33,8 +28,11 @@ import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.dto.auth.response.TokenRefreshResponse; import com.ibrasoft.lensbridge.dto.auth.response.TokenValidationResponse; +import com.ibrasoft.lensbridge.exception.RefreshTokenException; import com.ibrasoft.lensbridge.model.auth.RefreshToken; +import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.security.CurrentUser; import com.ibrasoft.lensbridge.security.LoginAttemptService; import com.ibrasoft.lensbridge.security.jwt.JwtUtils; import com.ibrasoft.lensbridge.service.RefreshTokenService; @@ -42,32 +40,23 @@ @RestController @RequestMapping("/api/auth") +@RequiredArgsConstructor +@Slf4j public class AuthController { - private static final Logger log = LoggerFactory.getLogger(AuthController.class); - @Autowired - AuthenticationManager authenticationManager; - - @Autowired - UserService userService; - - @Autowired - JwtUtils jwtUtils; - - @Autowired - LoginAttemptService loginAttemptService; - - @Autowired - RefreshTokenService refreshTokenService; + private final AuthenticationManager authenticationManager; + private final UserService userService; + private final JwtUtils jwtUtils; + private final LoginAttemptService loginAttemptService; + private final RefreshTokenService refreshTokenService; @PostMapping("/signin") - public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest, HttpServletRequest request) { - + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest, + HttpServletRequest request) { String clientKey = loginRequest.getEmail(); - // Check if account is temporarily locked due to failed attempts if (loginAttemptService.isBlocked(clientKey)) { - return ResponseEntity.status(429) // Too Many Requests + return ResponseEntity.status(429) .body(new MessageResponse("Account temporarily locked due to too many failed login attempts. Please try again later.")); } @@ -77,29 +66,14 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login SecurityContextHolder.getContext().setAuthentication(authentication); - User user = userService.findByEmail(authentication.getName()).orElse(null); - if (user == null) { - return ResponseEntity.status(401) - .body(new MessageResponse("User not found.")); - } - - if (!user.isVerified()) { - return ResponseEntity.badRequest() - .body(new MessageResponse("Please verify your email before logging in.")); - } + User user = userService.findByEmail(authentication.getName()) + .orElseThrow(() -> new RuntimeException("Authenticated user not found in database")); - // Record successful login loginAttemptService.recordSuccessfulAttempt(clientKey); String jwt = jwtUtils.generateJwtToken(authentication); - - // Create refresh token - String deviceInfo = request.getHeader("User-Agent"); - String ipAddress = getClientIpAddress(request); RefreshToken refreshToken = refreshTokenService.createRefreshToken( - user.getId(), deviceInfo, ipAddress); - - log.debug("Created refresh token for user {}: {}", user.getId(), refreshToken.getTokenHash()); + user.getId(), request.getHeader("User-Agent"), getClientIpAddress(request)); List roles = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) @@ -113,35 +87,25 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login user.getEmail(), roles)); } catch (Exception e) { - // Record failed login attempt loginAttemptService.recordFailedAttempt(clientKey); - throw e; // Let global exception handler deal with it + throw e; } } @PostMapping("/signup") - public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) throws Exception { - try { - userService.createUser(signUpRequest); - return ResponseEntity.ok(new MessageResponse("User registered successfully! Please check your email to verify your account.")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse("Error: " + e.getMessage())); - } + public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { + userService.createUser(signUpRequest); + return ResponseEntity.ok(new MessageResponse("User registered successfully! Please check your email to verify your account.")); } @PostMapping("/verify-email") - public ResponseEntity verifyEmail(@RequestBody(required = true) java.util.Map payload) { + public ResponseEntity verifyEmail(@RequestBody java.util.Map payload) { String token = payload.get("token"); if (token == null || token.isEmpty()) { return ResponseEntity.badRequest().body(new MessageResponse("Invalid verification token.")); } - - try { - userService.verifyUserEmail(token); - return ResponseEntity.ok(new MessageResponse("Email verified successfully!")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse(e.getMessage())); - } + userService.verifyUserEmail(token); + return ResponseEntity.ok(new MessageResponse("Email verified successfully!")); } @PostMapping("/forgot-password") @@ -149,9 +113,7 @@ public ResponseEntity requestPasswordReset(@RequestParam String email) { if (email == null || email.isEmpty()) { return ResponseEntity.badRequest().body(new MessageResponse("Email is required.")); } - userService.requestPasswordReset(email); - return ResponseEntity.ok(new MessageResponse("If an account with that email exists, a password reset email has been sent.")); } @@ -160,12 +122,10 @@ public ResponseEntity validateResetToken(@RequestParam String token) { if (token == null || token.isEmpty()) { return ResponseEntity.badRequest().body(new MessageResponse("Token is required.")); } - if (userService.validateResetToken(token)) { return ResponseEntity.ok(new MessageResponse("Token is valid.")); - } else { - return ResponseEntity.badRequest().body(new MessageResponse("Invalid or expired token.")); } + return ResponseEntity.badRequest().body(new MessageResponse("Invalid or expired token.")); } @PostMapping("/reset-password") @@ -173,187 +133,84 @@ public ResponseEntity resetPassword(@RequestParam String token, @RequestParam if (token == null || token.isEmpty() || newPassword == null || newPassword.isEmpty()) { return ResponseEntity.badRequest().body(new MessageResponse("Token and new password are required.")); } - - try { - userService.resetPassword(token, newPassword); - return ResponseEntity.ok(new MessageResponse("Password reset successfully.")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse(e.getMessage())); - } + userService.resetPassword(token, newPassword); + return ResponseEntity.ok(new MessageResponse("Password reset successfully.")); } @PostMapping("/change-password") + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest, - Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.badRequest().body(new MessageResponse("You must be logged in to change your password.")); - } - - User user = userService.findByEmail(authentication.getName()).orElse(null); - - if (user == null) { - return ResponseEntity.badRequest().body(new MessageResponse("User not found.")); - } - - try { - userService.changePassword(user, changePasswordRequest); - refreshTokenService.revokeAllUserTokens(user.getId()); - // Create new refresh token + jwt and return to user.... at some point - return ResponseEntity.ok(new MessageResponse("Password changed successfully. Please log in again.")); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new MessageResponse(e.getMessage())); - } + @CurrentUser User user) { + userService.changePassword(user, changePasswordRequest); + refreshTokenService.revokeAllUserTokens(user.getId()); + return ResponseEntity.ok(new MessageResponse("Password changed successfully. Please log in again.")); } @PostMapping("/refresh-token") public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest request) { String requestRefreshToken = request.getRefreshToken(); - log.debug("Attempting to refresh token: {}", requestRefreshToken); + RefreshToken refreshToken = refreshTokenService.findByToken(requestRefreshToken) + .orElseThrow(() -> new RefreshTokenException( + "Refresh token not found. Please login again.", HttpStatus.UNAUTHORIZED)); - try { - Optional refreshTokenOpt = refreshTokenService.findByToken(requestRefreshToken); - - if (refreshTokenOpt.isEmpty()) { - log.warn("Refresh token not found in database: {}", requestRefreshToken); - return ResponseEntity.status(401) - .body(new MessageResponse("Refresh token is not in database. Please login again.")); - } + RefreshToken verifiedToken = refreshTokenService.verifyExpiration(refreshToken); - RefreshToken refreshToken = refreshTokenOpt.get(); - log.debug("Found refresh token for user: {}", refreshToken.getUser().getId()); + UUID userId = verifiedToken.getUserId(); + User user = userService.findById(userId) + .orElseThrow(() -> new RefreshTokenException( + "User not found. Please login again.", HttpStatus.UNAUTHORIZED)); - // Verify token is not expired or revoked - RefreshToken verifiedToken = refreshTokenService.verifyExpiration(refreshToken); + List authorities = new ArrayList<>(user.getRoles()); + Authentication auth = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities); + String newAccessToken = jwtUtils.generateJwtToken(auth); - UUID userId = verifiedToken.getUserId(); - User user = userService.findById(userId).orElse(null); + refreshTokenService.revokeRefreshToken(requestRefreshToken); + RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(userId, "Token refresh", "System"); - if (user == null) { - log.warn("User not found for refresh token: {}", userId); - return ResponseEntity.status(401) - .body(new MessageResponse("User not found. Please login again.")); - } - - // Create new access token - List authorities = user.getRoles().stream() - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - Authentication auth = new UsernamePasswordAuthenticationToken( - user.getEmail(), null, authorities); - String newAccessToken = jwtUtils.generateJwtToken(auth); - - // Rotate refresh token (recommended for security) - refreshTokenService.revokeRefreshToken(requestRefreshToken); - RefreshToken newRefreshToken = refreshTokenService.createRefreshToken( - userId, "Token refresh", "System"); - - log.debug("Successfully refreshed token for user: {}, new refresh token: {}", userId, newRefreshToken.getToken()); - - return ResponseEntity.ok(new TokenRefreshResponse( - newAccessToken, newRefreshToken.getToken())); - - } catch (RuntimeException e) { - // Handle expired or revoked tokens - if (e.getMessage().contains("expired") || e.getMessage().contains("revoked")) { - log.warn("Refresh token expired or revoked: {}", e.getMessage()); - return ResponseEntity.status(401) - .body(new MessageResponse("Refresh token expired or revoked. Please login again.")); - } - - // Log unexpected errors - log.error("Error refreshing token: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("An error occurred while refreshing token. Please try again.")); - } + return ResponseEntity.ok(new TokenRefreshResponse(newAccessToken, newRefreshToken.getToken())); } @PostMapping("/logout") - public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request, - Authentication authentication) { - try { - // Revoke refresh token if provided - if (request != null && request.getRefreshToken() != null) { - refreshTokenService.revokeRefreshToken(request.getRefreshToken()); - } - - // If user is authenticated, we could also revoke all their tokens - if (authentication != null && authentication.isAuthenticated()) { - // Optionally revoke all user tokens here if needed - // refreshTokenService.revokeAllUserTokens(user.getId()); - } - - return ResponseEntity.ok(new MessageResponse("Logged out successfully")); - } catch (Exception e) { - return ResponseEntity.ok(new MessageResponse("Logged out successfully")); // Always return success for logout + public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { + if (request != null && request.getRefreshToken() != null) { + refreshTokenService.revokeRefreshToken(request.getRefreshToken()); } + return ResponseEntity.ok(new MessageResponse("Logged out successfully")); } @PostMapping("/logout-all-devices") - public ResponseEntity logoutAllDevices(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.badRequest().body(new MessageResponse("You must be logged in.")); - } - - User user = userService.findByEmail(authentication.getName()).orElse(null); - if (user == null) { - return ResponseEntity.status(401) - .body(new MessageResponse("User not found")); - } - + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity logoutAllDevices(@CurrentUser User user) { refreshTokenService.revokeAllUserTokens(user.getId()); - return ResponseEntity.ok(new MessageResponse("Logged out from all devices successfully")); } - @PostMapping("/validate-token") + @GetMapping("/validate-token") public ResponseEntity validateToken(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Invalid or expired token")); + return ResponseEntity.status(401).body(new MessageResponse("Invalid or expired token")); } - - try { - User user = userService.findByEmail(authentication.getName()).orElse(null); - - if (user == null) { - return ResponseEntity.status(401) - .body(new MessageResponse("User not found")); - } - - // Return user information if token is valid - return ResponseEntity.ok(new TokenValidationResponse( + User user = userService.findByEmail(authentication.getName()).orElse(null); + if (user == null) { + return ResponseEntity.status(401).body(new MessageResponse("User not found")); + } + return ResponseEntity.ok(new TokenValidationResponse( true, user.getId(), user.getFirstName(), user.getLastName(), user.getEmail(), user.isVerified(), - user.getRoles().stream().collect(Collectors.toList()) - )); - - } catch (Exception e) { - log.error("Error validating token: {}", e.getMessage(), e); - return ResponseEntity.status(401) - .body(new MessageResponse("Invalid or expired token")); - } - } - - @GetMapping("/validate-token") - public ResponseEntity validateTokenGet(Authentication authentication) { - return validateToken(authentication); + user.getRoles().stream().map(Role::getAuthority).collect(Collectors.toList()) + )); } - /** - * Helper method to get client IP address - */ private String getClientIpAddress(HttpServletRequest request) { - String xForwardedForHeader = request.getHeader("X-Forwarded-For"); - if (xForwardedForHeader == null) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor == null) { return request.getRemoteAddr(); - } else { - // X-Forwarded-For can contain multiple IPs, get the first one - return xForwardedForHeader.split(",")[0].trim(); } + return xForwardedFor.split(",")[0].trim(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index c1845bc..0bcd62c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -45,7 +45,7 @@ @RestController @RequestMapping("/api/admin/board") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.ROOT + "')") +@PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") @Slf4j public class BoardAdminController { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index 3730c6c..7ff04c9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -35,7 +35,7 @@ @RestController @RequestMapping("/api/admin/board/devices") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.ROOT + "')") +@PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") @Slf4j public class DeviceAdminController { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java index e5fdbd1..320ffdc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java @@ -1,15 +1,12 @@ package com.ibrasoft.lensbridge.controller; import com.ibrasoft.lensbridge.model.upload.MediaEvent; -import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.service.EventsService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import java.util.UUID; @RestController @@ -21,20 +18,13 @@ public class EventsController { @GetMapping public ResponseEntity> getAllEvents() { - eventsService.cleanUpOldEvents(); - List mediaEvents = eventsService.getAllEvents().stream() - .filter(event -> event.getStatus() == EventStatus.ONGOING || - (event.getStatus() == EventStatus.PAST && - event.getDate().isAfter(LocalDateTime.now().minusDays(7)))) - .toList(); - return ResponseEntity.ok(mediaEvents); + return ResponseEntity.ok(eventsService.getPublicVisibleEvents()); } - @GetMapping("/{id}") public ResponseEntity getEventById(@PathVariable UUID id) { - Optional event = eventsService.getEventById(id); - return event.map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); + return eventsService.getEventById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index c277ef5..8643e3f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -1,26 +1,23 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.config.UploadProperties; -import com.ibrasoft.lensbridge.dto.upload.response.*; -import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.service.DirectUploadService; +import com.ibrasoft.lensbridge.security.CurrentUser; +import com.ibrasoft.lensbridge.service.UploadLimitsService; import com.ibrasoft.lensbridge.service.UploadService; -import com.ibrasoft.lensbridge.service.UserService; +import com.ibrasoft.lensbridge.service.UploadWorkflowService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.util.unit.DataSize; import org.springframework.web.bind.annotation.*; -import java.util.Collection; + import java.util.UUID; @RestController @@ -30,184 +27,60 @@ public class FileUploadController { private final UploadService uploadService; - private final DirectUploadService directUploadService; - private final UploadProperties uploadProperties; - private final UserService userService; + private final UploadWorkflowService uploadWorkflowService; + private final UploadLimitsService uploadLimitsService; @GetMapping("/{uploadId}") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUploadById(@PathVariable UUID uploadId) { - try { - return uploadService.getUploadById(uploadId).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of("Error fetching upload: " + e.getMessage())); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUploadById(@PathVariable UUID uploadId) { + return uploadService.getUploadById(uploadId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); } @GetMapping("/event/{eventId}") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUploadsByEvent(@PathVariable UUID eventId, Pageable pageable) { - try { - Page uploads = uploadService.getUploadsByEvent(eventId, pageable); - return ResponseEntity.ok(uploads); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of("Error fetching uploads: " + e.getMessage())); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity> getUploadsByEvent(@PathVariable UUID eventId, Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByEvent(eventId, pageable)); } - /** - * Generate a presigned URL for direct upload to R2 for a specific event. - * This allows files to be uploaded directly to R2 without going through the backend. - */ @PostMapping("/{eventId}/direct/presign") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity generatePresignedUploadUrl( + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity generatePresignedUploadUrl( @PathVariable UUID eventId, @RequestParam String filename, @RequestParam String contentType, @RequestParam long fileSize, @RequestParam String expectedSha256, + @CurrentUser User user, Authentication authentication) { - try { - User user = getCurrentUser(authentication); - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ErrorResponse.of("Authentication required")); - } - String highestRole = getHighestRole(authentication); - - PresignedUploadResponse response = directUploadService.createPresignedUpload( - eventId, - filename, - contentType, - fileSize, - expectedSha256, - user.getId(), - highestRole); - - return ResponseEntity.ok(response); - } catch (ApiResponseException e) { - return ResponseEntity.status(e.getStatus()).body(e.getBody()); - } catch (Exception e) { - log.error("Failed to generate presigned upload URL for event: {}", eventId, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of("Failed to generate upload URL: " + e.getMessage())); - } + Role role = uploadLimitsService.getHighestRole(authentication); + return ResponseEntity.ok(uploadWorkflowService.initiateUpload( + eventId, filename, contentType, fileSize, expectedSha256, user.getId(), role)); } - /** - * Complete a direct upload by creating the Upload entity in the database. - * This should be called after the file has been successfully uploaded to R2. - */ @PostMapping("/{eventId}/direct/complete") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity completeDirectUpload( + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity completeDirectUpload( @PathVariable UUID eventId, @RequestParam String objectKey, @RequestParam String filename, @RequestParam String contentType, @RequestParam long fileSize, - @RequestParam(value = "instagramHandle", required = false) String instagramHandle, - @RequestParam(value = "description", required = false) String description, - @RequestParam(value = "anon", defaultValue = "false") boolean anon, + @RequestParam(required = false) String instagramHandle, + @RequestParam(required = false) String description, + @RequestParam(defaultValue = "false") boolean anon, @RequestParam String expectedSha256, - Authentication authentication) { - try { - User user = getCurrentUser(authentication); - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ErrorResponse.of("Authentication required")); - } - - UploadCompletionResponse response = directUploadService.completeDirectUpload( - eventId, - objectKey, - filename, - contentType, - fileSize, - instagramHandle, - description, - anon, - expectedSha256, - user.getId()); - - return ResponseEntity.ok(response); - } catch (ApiResponseException e) { - return ResponseEntity.status(e.getStatus()).body(e.getBody()); - } catch (Exception e) { - log.error("Failed to complete direct upload for event '{}', objectKey: {}", eventId, objectKey, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of("Failed to complete upload: " + e.getMessage())); - } + @CurrentUser User user) { + return ResponseEntity.ok(uploadWorkflowService.completeUpload( + eventId, objectKey, filename, contentType, fileSize, + instagramHandle, description, anon, expectedSha256, user.getId())); } - /** - * Get upload limits for the current user. - */ @GetMapping("/limits") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUploadLimits(Authentication authentication) { - try { - User user = getCurrentUser(authentication); - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ErrorResponse.of("Authentication required")); - } - String highestRole = getHighestRole(authentication); - DataSize maxSize = uploadProperties.getMaxSizeForRole(highestRole); - int dailyLimit = uploadProperties.getDailyLimitForRole(highestRole); - long uploadsToday = uploadService.countUploadsToday(user.getId()); - - UploadLimitsResponse limits = UploadLimitsResponse.builder() - .role(highestRole) - .maxSizeBytes(maxSize.toBytes()) - .maxSizeMB(maxSize.toMegabytes()) - .allowedContentTypes(uploadProperties.getAllowedFileTypes()) - .videoMaxDurationSeconds(uploadProperties.getVideoMaxduration()) - .dailyLimit(dailyLimit) - .uploadsToday(uploadsToday) - .uploadsRemaining(Math.max(0, dailyLimit - uploadsToday)) - .build(); - - return ResponseEntity.ok(limits); - } catch (Exception e) { - log.error("Failed to get upload limits", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of("Failed to get upload limits")); - } - } - - /** - * Get the highest role for the authenticated user. - */ - private String getHighestRole(Authentication authentication) { - if (authentication == null || authentication.getAuthorities() == null) { - return "verified"; // Default to verified (lowest role) - } - - Collection authorities = authentication.getAuthorities(); - - if (authorities.stream().anyMatch(a -> Role.ROOT.equals(a.getAuthority()))) { - return "root"; - } - if (authorities.stream().anyMatch(a -> Role.ADMIN.equals(a.getAuthority()))) { - return "admin"; - } - if (authorities.stream().anyMatch(a -> Role.MODERATOR.equals(a.getAuthority()))) { - return "moderator"; - } - if (authorities.stream().anyMatch(a -> Role.VERIFIED.equals(a.getAuthority()))) { - return "verified"; - } - - return "verified"; // Default to verified + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUploadLimits(@CurrentUser User user, Authentication authentication) { + Role role = uploadLimitsService.getHighestRole(authentication); + return ResponseEntity.ok(uploadLimitsService.getLimitsForRole(role, user.getId())); } - - private User getCurrentUser(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return null; - } - return userService.findByEmail(authentication.getName()).orElse(null); - } - } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java index ab0eeb2..da4fc68 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.service.GalleryService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; @@ -13,29 +14,18 @@ @RestController @RequestMapping("/api") @RequiredArgsConstructor +@Slf4j public class GalleryController { private final GalleryService galleryService; @GetMapping("/gallery") public ResponseEntity> getAllUploads(Pageable pageable) { - try { - Page response = galleryService.getAllApprovedGalleryItems(pageable); - return ResponseEntity.ok(response); - } catch (Exception e) { - System.err.println("Error fetching gallery: " + e.getMessage()); - return ResponseEntity.ok(Page.empty()); - } + return ResponseEntity.ok(galleryService.getAllApprovedGalleryItems(pageable)); } @GetMapping("/gallery/event/{eventId}") public ResponseEntity> getGalleryByEvent(@PathVariable UUID eventId, Pageable pageable) { - try { - Page response = galleryService.getGalleryItemsByEvent(eventId, pageable); - return ResponseEntity.ok(response); - } catch (Exception e) { - System.err.println("Error fetching gallery for event: " + e.getMessage()); - return ResponseEntity.ok(Page.empty()); - } + return ResponseEntity.ok(galleryService.getGalleryItemsByEvent(eventId, pageable)); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java index 1176fdb..b3d8958 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java @@ -4,197 +4,73 @@ import jakarta.validation.Valid; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; -import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; +import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.security.CurrentUser; +import com.ibrasoft.lensbridge.service.GalleryService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; @RestController @RequestMapping("/api/user") +@RequiredArgsConstructor +@Slf4j public class UserController { - private static final Logger log = LoggerFactory.getLogger(UserController.class); - - @Autowired - UserService userService; - - @Autowired - UploadService uploadService; - @GetMapping("/profile") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUserProfile(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } + private final UserService userService; + private final UploadService uploadService; + private final GalleryService galleryService; - try { - User user = userService.findByEmail(authentication.getName()).orElse(null); - - if (user == null) { - return ResponseEntity.status(404) - .body(new MessageResponse("User not found")); - } - - UserInfoResponse response = new UserInfoResponse( - user.getId(), - user.getFirstName(), - user.getLastName(), - user.getEmail(), - user.isVerified(), - user.getRoles() - ); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - log.error("Error retrieving user profile: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error retrieving profile")); - } + @GetMapping("/profile") + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUserProfile(@CurrentUser User user) { + return ResponseEntity.ok(new UserInfoResponse( + user.getId(), user.getFirstName(), user.getLastName(), + user.getEmail(), user.isVerified(), user.getRoles())); } @PatchMapping("/profile") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity updateUserProfile(@Valid @RequestBody UpdateProfileRequest updateRequest, - Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - User currentUser = userService.findByEmail(authentication.getName()).orElse(null); - if (currentUser == null) { - return ResponseEntity.status(404) - .body(new MessageResponse("User not found")); - } - - User updatedUser = userService.updateProfile(currentUser.getId(), updateRequest); - - UserInfoResponse response = new UserInfoResponse( - updatedUser.getId(), - updatedUser.getFirstName(), - updatedUser.getLastName(), - updatedUser.getEmail(), - updatedUser.isVerified(), - updatedUser.getRoles() - ); - - return ResponseEntity.ok(response); - - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest() - .body(new MessageResponse("Error: " + e.getMessage())); - } catch (Exception e) { - log.error("Error updating user profile: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error updating profile")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity updateUserProfile( + @Valid @RequestBody UpdateProfileRequest updateRequest, + @CurrentUser User user) { + User updated = userService.updateProfile(user.getId(), updateRequest); + return ResponseEntity.ok(new UserInfoResponse( + updated.getId(), updated.getFirstName(), updated.getLastName(), + updated.getEmail(), updated.isVerified(), updated.getRoles())); } @GetMapping("/stats") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUserStats(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - User user = userService.findByEmail(authentication.getName()).orElse(null); - if (user == null) { - return ResponseEntity.status(404) - .body(new MessageResponse("User not found")); - } - - UserStatsResponse stats = uploadService.getUserStats(user.getId()); - - return ResponseEntity.ok(stats); - - } catch (Exception e) { - log.error("Error retrieving user stats: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error retrieving stats")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity getUserStats(@CurrentUser User user) { + return ResponseEntity.ok(uploadService.getUserStats(user.getId())); } @GetMapping("/uploads") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity getUserUploads(Pageable pageable, Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - User user = userService.findByEmail(authentication.getName()).orElse(null); - if (user == null) { - return ResponseEntity.status(404) - .body(new MessageResponse("User not found")); - } - - Page uploads = uploadService.getUserUploadsAsGalleryItems(user.getId(), pageable); - - return ResponseEntity.ok(uploads); - - } catch (Exception e) { - log.error("Error retrieving user uploads: {}", e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error retrieving uploads")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity> getUserUploads(Pageable pageable, @CurrentUser User user) { + return ResponseEntity.ok(galleryService.getUserGallery(user.getId(), pageable)); } @DeleteMapping("/uploads/{uploadId}") - @PreAuthorize("hasRole('" + Role.VERIFIED + "')") - public ResponseEntity deleteUserUpload(@PathVariable UUID uploadId, Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401) - .body(new MessageResponse("Authentication required")); - } - - try { - User user = userService.findByEmail(authentication.getName()).orElse(null); - if (user == null) { - return ResponseEntity.status(404) - .body(new MessageResponse("User not found")); - } - - uploadService.deleteUserUpload(uploadId, user.getId()); - - return ResponseEntity.ok(new MessageResponse("Upload deleted successfully")); - - } catch (IllegalArgumentException e) { - return ResponseEntity.status(404) - .body(new MessageResponse("Error: " + e.getMessage())); - } catch (SecurityException e) { - return ResponseEntity.status(403) - .body(new MessageResponse("Error: " + e.getMessage())); - } catch (Exception e) { - log.error("Error deleting user upload {}: {}", uploadId, e.getMessage(), e); - return ResponseEntity.status(500) - .body(new MessageResponse("Error deleting upload")); - } + @PreAuthorize("hasRole('" + Role.Authority.USER + "')") + public ResponseEntity deleteUserUpload( + @PathVariable UUID uploadId, + @CurrentUser User user) { + uploadService.deleteUserUpload(uploadId, user.getId()); + return ResponseEntity.ok(new MessageResponse("Upload deleted successfully")); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java b/src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java new file mode 100644 index 0000000..b9c3218 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/DailyLimitExceededException.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +@Getter +public class DailyLimitExceededException extends RuntimeException { + private final int limit; + private final long current; + + public DailyLimitExceededException(int limit, long current) { + super("Daily upload limit of " + limit + " reached (" + current + " uploaded today)"); + this.limit = limit; + this.current = current; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java b/src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java new file mode 100644 index 0000000..069a4a4 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/EventNotAcceptingUploadsException.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +import java.util.UUID; + +@Getter +public class EventNotAcceptingUploadsException extends RuntimeException { + private final UUID eventId; + + public EventNotAcceptingUploadsException(UUID eventId) { + super("Event " + eventId + " is not currently accepting uploads"); + this.eventId = eventId; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java b/src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java new file mode 100644 index 0000000..7363d3c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/FileSizeLimitExceededException.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +@Getter +public class FileSizeLimitExceededException extends RuntimeException { + private final long maxBytes; + private final long actualBytes; + + public FileSizeLimitExceededException(long maxBytes, long actualBytes) { + super("File size " + (actualBytes / 1024 / 1024) + "MB exceeds limit of " + (maxBytes / 1024 / 1024) + "MB"); + this.maxBytes = maxBytes; + this.actualBytes = actualBytes; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java index b4c0a84..744dba8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java @@ -1,6 +1,9 @@ package com.ibrasoft.lensbridge.exception; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.upload.response.DailyLimitErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.FileSizeErrorResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -22,49 +25,70 @@ @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { + @ExceptionHandler(ApiResponseException.class) public ResponseEntity handleApiResponseException(ApiResponseException ex) { return ResponseEntity.status(ex.getStatus()).body(ex.getBody()); } + @ExceptionHandler(RefreshTokenException.class) + public ResponseEntity handleRefreshToken(RefreshTokenException ex) { + log.warn("Refresh token rejected: {}", ex.getMessage()); + return ResponseEntity.status(ex.getStatus()).body(new MessageResponse(ex.getMessage())); + } - @ExceptionHandler(VideoProcessingException.class) - public ResponseEntity> handleVideoProcessingException(VideoProcessingException ex) { - log.error("Video processing error: {}", ex.getMessage()); - Map response = new HashMap<>(); - response.put("error", "Video Processing Error"); - response.put("message", ex.getMessage()); - response.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(new MessageResponse(ex.getMessage())); } - @ExceptionHandler(VideoTooLongException.class) - public ResponseEntity> handleVideoTooLongException(VideoTooLongException ex) { - log.error("Video too long error: {}", ex.getMessage()); - Map response = new HashMap<>(); - response.put("error", "Video Too Long"); - response.put("message", ex.getMessage()); - response.put("status", HttpStatus.BAD_REQUEST.value()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + @ExceptionHandler(SecurityException.class) + public ResponseEntity handleSecurity(SecurityException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse(ex.getMessage())); + } + + @ExceptionHandler(DailyLimitExceededException.class) + public ResponseEntity handleDailyLimitExceeded(DailyLimitExceededException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(DailyLimitErrorResponse.of(ex.getMessage(), ex.getLimit(), ex.getCurrent(), "unknown")); + } + + @ExceptionHandler(FileSizeLimitExceededException.class) + public ResponseEntity handleFileSizeLimitExceeded(FileSizeLimitExceededException ex) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(FileSizeErrorResponse.of(ex.getMessage(), + (ex.getMaxBytes() / 1024 / 1024) + "MB", + (ex.getActualBytes() / 1024 / 1024) + "MB")); + } + + @ExceptionHandler(InvalidContentTypeException.class) + public ResponseEntity handleInvalidContentType(InvalidContentTypeException ex) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(ErrorResponse.of(ex.getMessage())); + } + + @ExceptionHandler(EventNotAcceptingUploadsException.class) + public ResponseEntity handleEventNotAcceptingUploads(EventNotAcceptingUploadsException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ErrorResponse.of(ex.getMessage())); } @ExceptionHandler(MaxUploadSizeExceededException.class) public ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) { - log.error("File size too large: {}", ex.getMessage()); + log.error("Multipart upload too large: {}", ex.getMessage()); Map response = new HashMap<>(); response.put("error", "File Too Large"); response.put("message", "The uploaded file exceeds the maximum allowed size"); - response.put("status", HttpStatus.BAD_REQUEST.value()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + response.put("status", HttpStatus.PAYLOAD_TOO_LARGE.value()); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { Map errors = new HashMap<>(); - ex.getBindingResult().getAllErrors().forEach((error) -> { + ex.getBindingResult().getAllErrors().forEach(error -> { String fieldName = ((FieldError) error).getField(); - String errorMessage = error.getDefaultMessage(); - errors.put(fieldName, errorMessage); + errors.put(fieldName, error.getDefaultMessage()); }); return ResponseEntity.badRequest().body(errors); } @@ -89,7 +113,7 @@ public ResponseEntity handleDisabledException(DisabledException @ExceptionHandler(AuthorizationDeniedException.class) public ResponseEntity handleAuthorizationDeniedException(AuthorizationDeniedException ex) { - log.warn("Authorization failed - access denied: {}", ex.getMessage()); + log.warn("Authorization failed: {}", ex.getMessage()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse("Access denied.")); } @@ -101,19 +125,18 @@ public ResponseEntity handleHttpMessageNotReadableException(Htt @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { - log.error("HTTP method not supported: ", ex); return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(new MessageResponse("HTTP method not supported")); } @ExceptionHandler(FileProcessingException.class) - public ResponseEntity handleIOException(FileProcessingException ex) { - log.error("I/O error occurred: {}", ex.getMessage(), ex); + public ResponseEntity handleFileProcessingException(FileProcessingException ex) { + log.error("File processing error: {}", ex.getMessage(), ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to process upload")); } @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + log.error("Unexpected error: {}", ex.getMessage(), ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("An unexpected error occurred")); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java b/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java new file mode 100644 index 0000000..8456933 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.exception; + +import lombok.Getter; + +@Getter +public class InvalidContentTypeException extends RuntimeException { + private final String contentType; + + public InvalidContentTypeException(String contentType) { + super("Content type not allowed: " + contentType); + this.contentType = contentType; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java b/src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java new file mode 100644 index 0000000..83ea21e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/exception/RefreshTokenException.java @@ -0,0 +1,16 @@ +package com.ibrasoft.lensbridge.exception; + +import org.springframework.http.HttpStatus; + +public class RefreshTokenException extends RuntimeException { + private final HttpStatus status; + + public RefreshTokenException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java b/src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java deleted file mode 100644 index 0bbca5d..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/exception/VideoProcessingException.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.ibrasoft.lensbridge.exception; -public class VideoProcessingException extends RuntimeException { - public VideoProcessingException(String message) { - super(message); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java b/src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java deleted file mode 100644 index d631ed6..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/exception/VideoTooLongException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ibrasoft.lensbridge.exception; - -public class VideoTooLongException extends RuntimeException { - public VideoTooLongException(String message) { - super(message); - } - - public VideoTooLongException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index e91daf2..1380931 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -13,6 +13,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Index; @@ -22,6 +23,7 @@ import jakarta.validation.constraints.Size; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Data; @@ -30,6 +32,7 @@ @AllArgsConstructor @NoArgsConstructor @Data +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) @Entity @Table(name = "users", indexes = { @Index(name = "idx_users_email", columnList = "email") @@ -61,12 +64,13 @@ public class User { @Column(nullable = false) private String passwordHash; - @ElementCollection(targetClass = Role.class) + @ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER) @Enumerated(EnumType.STRING) @CollectionTable(name = "user_roles") @Column(name = "role", nullable = false) private Set roles = new HashSet<>(); + @Column(nullable = true) private Instant verifiedAt; public User(String firstName, String lastName, String studentNumber, String email, String passwordHash) { diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java index cd0aef8..74e36d2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/VerificationToken.java @@ -5,6 +5,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -13,14 +15,23 @@ import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Builder @AllArgsConstructor @NoArgsConstructor +@Getter +@Setter @Table(name = "verification_tokens") public class VerificationToken { + + public enum TokenType { + EMAIL_VERIFICATION, PASSWORD_RESET + } + @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @@ -32,8 +43,11 @@ public class VerificationToken { @JoinColumn(name = "user_id", nullable = false) private User user; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TokenType type; + private Instant createdAt; private Instant expiresAt; - private Instant usedAt; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java index 3a452ef..84c024f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/MediaEvent.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.model.upload; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,6 +23,7 @@ @AllArgsConstructor @Data @Builder +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) @Entity @Table(name = "events") public class MediaEvent { diff --git a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java index 922533e..eaa21a9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/upload/Upload.java @@ -46,11 +46,11 @@ public class Upload { private String instagramHandle; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "uploaded_by", nullable = false) private User uploadedBy; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "event_id") private MediaEvent mediaEvent; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java index 6c6265c..d03c8e7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/VerificationTokenRepository.java @@ -10,6 +10,7 @@ import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.model.auth.VerificationToken.TokenType; @Repository public interface VerificationTokenRepository extends JpaRepository { @@ -17,8 +18,8 @@ public interface VerificationTokenRepository extends JpaRepository current_timestamp") - Optional findValidToken(@Param("tokenHash") String tokenHash); + @Query("select v from VerificationToken v where v.tokenHash = :tokenHash and v.type = :type and v.usedAt is null and v.expiresAt > :now") + Optional findValidToken(@Param("tokenHash") String tokenHash, @Param("type") TokenType type, @Param("now") java.time.Instant now); @Query("select v from VerificationToken v where v.user = :user order by v.createdAt desc limit 1") Optional findLatestByUser(@Param("user") User user); diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java index 02ecb2f..e315321 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/upload/UploadRepository.java @@ -17,6 +17,9 @@ public interface UploadRepository extends JpaRepository { /** * Recall that deletedAt = null => not deleted. Therefore, this method will only return uploads that are not marked as deleted. */ + + Upload findByUuidAndDeletedAtIsNull(UUID uuid); + Page findByDeletedAtIsNull(Pageable pageable); Page findByApprovedTrueAndDeletedAtIsNull(Pageable pageable); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java new file mode 100644 index 0000000..0050f85 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUser.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface CurrentUser { +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..01c87d9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/CurrentUserArgumentResolver.java @@ -0,0 +1,41 @@ +package com.ibrasoft.lensbridge.security; + +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final UserService userService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && User.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new UsernameNotFoundException("No authenticated user in security context"); + } + return userService.findByEmail(authentication.getName()) + .orElseThrow(() -> new UsernameNotFoundException( + "Authenticated user not found in database: " + authentication.getName())); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java index e6cfff6..0897abc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/jwt/JwtUtils.java @@ -21,7 +21,7 @@ public class JwtUtils { private String jwtSecret; @Value("${lensbridge.app.jwtExpirationMs}") - private int jwtExpirationMs; + private long jwtExpirationMs; public String generateJwtToken(Authentication authentication) { return Jwts.builder() @@ -43,7 +43,7 @@ public String getUserNameFromJwtToken(String token) { public boolean validateJwtToken(String authToken) { try { - Jwts.parserBuilder().setSigningKey(key()).build().parse(authToken); + Jwts.parserBuilder().setSigningKey(key()).build().parseClaimsJws(authToken); return true; } catch (MalformedJwtException e) { logger.error("Invalid JWT token: {}", e.getMessage()); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index 6a73494..1ac5c7a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -2,7 +2,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -11,8 +10,8 @@ import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; @Service public class UserDetailsServiceImpl implements UserDetailsService { @@ -24,14 +23,13 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx User user = userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); - List authorities = user.getRoles().stream() - .map(role -> new SimpleGrantedAuthority(role.getAuthority())) - .collect(Collectors.toList()); + List authorities = new ArrayList<>(user.getRoles()); return org.springframework.security.core.userdetails.User .withUsername(user.getEmail()) .password(user.getPassword()) .authorities(authorities) + .disabled(!user.isVerified()) .build(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java deleted file mode 100644 index 588707f..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/DirectUploadService.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.config.UploadProperties; -import com.ibrasoft.lensbridge.dto.upload.response.DailyLimitErrorResponse; -import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; -import com.ibrasoft.lensbridge.dto.upload.response.FileSizeErrorResponse; -import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; -import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; -import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.upload.Upload; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.util.unit.DataSize; - -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Slf4j -public class DirectUploadService { - - private final UploadService uploadService; - private final R2StorageService r2StorageService; - private final UploadProperties uploadProperties; - private final EventsService eventsService; - private final ThumbnailService thumbnailService; - - public PresignedUploadResponse createPresignedUpload( - UUID eventId, - String filename, - String contentType, - long fileSize, - String expectedSha256, - UUID userId, - String highestRole) { - - validateEventAcceptingUploads(eventId); - enforceDailyLimit(userId, highestRole); - validateContentType(contentType); - - DataSize maxAllowed = uploadProperties.getMaxSizeForRole(highestRole); - if (fileSize > maxAllowed.toBytes()) { - throw new ApiResponseException( - HttpStatus.PAYLOAD_TOO_LARGE, - FileSizeErrorResponse.of( - "File size exceeds limit for role " + highestRole, - maxAllowed.toMegabytes() + "MB", - (fileSize / 1024 / 1024) + "MB"), - "File size exceeds role limit"); - } - - String objectKey = resolveObjectKey(contentType); - String presignedUrl = r2StorageService.generatePresignedUploadUrl( - objectKey, - contentType, - expectedSha256, - fileSize); - - PresignedUploadResponse.PresignedUploadResponseBuilder responseBuilder = PresignedUploadResponse.builder() - .uploadUrl(presignedUrl) - .objectKey(objectKey) - .eventId(eventId) - .method("PUT") - .contentType(contentType) - .expiresInMinutes(15); - - if (expectedSha256 != null) { - responseBuilder.expectedSha256(expectedSha256); - } - - PresignedUploadResponse response = responseBuilder.build(); - - log.info("Generated presigned upload URL for event '{}', user role '{}', file: '{}', size: {}MB", - eventId, highestRole, filename, fileSize / 1024 / 1024); - - return response; - } - - public UploadCompletionResponse completeDirectUpload( - UUID eventId, - String objectKey, - String filename, - String contentType, - long fileSize, - String instagramHandle, - String description, - boolean anon, - String expectedSha256, - UUID userId) { - - if (!r2StorageService.objectExists(objectKey)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("File not found in storage: " + objectKey), - "File not found in storage"); - } - - // TODO: Implement size and hash verification methods in R2StorageService - // long storedSize = r2StorageService.getObjectContentLength(objectKey); - // if (storedSize != fileSize) { - // throw new ApiResponseException( - // HttpStatus.BAD_REQUEST, - // ErrorResponse.of("File size mismatch in storage"), - // "File size mismatch in storage"); - // } - - // if (expectedSha256 != null) { - // try { - // String actualSha256 = r2StorageService.calculateSha256Hash(objectKey); - // if (!actualSha256.equalsIgnoreCase(expectedSha256)) { - // throw new ApiResponseException( - // HttpStatus.BAD_REQUEST, - // ErrorResponse.of("File integrity verification failed"), - // "File integrity verification failed"); - // } - // } catch (ApiResponseException e) { - // throw e; - // } catch (Exception e) { - // throw new ApiResponseException( - // HttpStatus.BAD_REQUEST, - // ErrorResponse.of("Failed to verify file integrity: " + e.getMessage()), - // "Failed to verify file integrity"); - // } - // } - - Upload upload = uploadService.createDirectUpload( - objectKey, - filename, - contentType, - eventId, - description, - instagramHandle, - anon, - userId); - - if (contentType != null && contentType.startsWith("image")) { - thumbnailService.generateThumbnailAsync(upload.getUuid(), objectKey); - log.debug("Triggered async thumbnail generation for upload: {}", upload.getUuid()); - } - - UploadCompletionResponse response = UploadCompletionResponse.builder() - .uploadId(upload.getUuid()) - .objectKey(objectKey) - .eventId(eventId) - .verified(true) - .fileSize(fileSize) - .build(); - - log.info("Successfully completed direct upload for event '{}': {}", eventId, upload.getUuid()); - return response; - } - - private void validateEventAcceptingUploads(UUID eventId) { - if (!eventsService.isEventAcceptingUploads(eventId)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("Event is not accepting uploads"), - "Event is not accepting uploads"); - } - } - - private void enforceDailyLimit(UUID userId, String role) { - int dailyLimit = uploadProperties.getDailyLimitForRole(role); - if (uploadService.hasReachedDailyLimit(userId, dailyLimit)) { - long uploadCount = uploadService.countUploadsToday(userId); - throw new ApiResponseException( - HttpStatus.TOO_MANY_REQUESTS, - DailyLimitErrorResponse.of( - "Daily upload limit exceeded", - dailyLimit, - uploadCount, - role), - "Daily upload limit exceeded"); - } - } - - private void validateContentType(String contentType) { - if (contentType == null || !uploadProperties.getAllowedFileTypes().contains(contentType)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("Content type not allowed: " + contentType), - "Content type not allowed"); - } - } - - private String resolveObjectKey(String contentType) { - String folder; - if (contentType != null && contentType.startsWith("image")) { - folder = "images/"; - } else if (contentType != null && contentType.startsWith("video")) { - folder = "videos/"; - } else { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("Unsupported content type: " + contentType), - "Unsupported content type"); - } - return folder + UUID.randomUUID(); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java index 7e81b3e..39c207e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/EventsService.java @@ -6,6 +6,8 @@ import com.ibrasoft.lensbridge.repository.upload.EventsRepository; import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -22,19 +24,41 @@ public class EventsService { private final EventsRepository eventsRepository; + @Value("${lensbridge.app.daysCutoffForPastEvent:7}") + private int daysCutoffForPastEvent; + public MediaEvent createEvent(CreateEventDto event) { MediaEvent newMediaEvent = MediaEvent.builder() .name(event.getName()) .date(event.getDate()) + .status(computeStatus(event.getDate(), Instant.now())) .build(); - + return eventsRepository.save(newMediaEvent); } + private EventStatus computeStatus(Instant eventDate, Instant now) { + ZoneId zone = ZoneId.systemDefault(); + LocalDate today = LocalDate.ofInstant(now, zone); + LocalDate eventDay = LocalDate.ofInstant(eventDate, zone); + + if (eventDay.isBefore(today)) return EventStatus.PAST; + if (eventDay.isAfter(today)) return EventStatus.UPCOMING; + return now.isBefore(eventDate) ? EventStatus.UPCOMING : EventStatus.ONGOING; + } + public List getAllEvents() { return eventsRepository.findAll(); } + public List getPublicVisibleEvents() { + Instant cutoff = Instant.now().minus(java.time.Duration.ofDays(daysCutoffForPastEvent)); + return eventsRepository.findAll().stream() + .filter(e -> e.getStatus() == EventStatus.ONGOING || + (e.getStatus() == EventStatus.PAST && e.getDate().isAfter(cutoff))) + .toList(); + } + public MediaEvent createEvent(String eventName, Instant eventDate) { return createEvent(CreateEventDto.builder().name(eventName).date(eventDate).build()); } @@ -51,8 +75,8 @@ public void deleteEvent(UUID id) { eventsRepository.deleteById(id); } - @Scheduled(cron = "0 0 0 * * ?") // Runs daily at midnight - public void cleanUpOldEvents() { + @Scheduled(cron = "0 0 0 * * ?") + void cleanUpOldEvents() { this.cleanUpOldEvents(Instant.now()); } @@ -70,30 +94,13 @@ public boolean isEventAcceptingUploads(UUID eventId) { } public void cleanUpOldEvents(Instant now) { - ZoneId zone = ZoneId.systemDefault(); - LocalDate today = LocalDate.ofInstant(now, zone); List allMediaEvents = eventsRepository.findAll(); for (MediaEvent mediaEvent : allMediaEvents) { if (mediaEvent.getDate() == null) continue; - LocalDate eventDate = LocalDate.ofInstant(mediaEvent.getDate(), zone); - EventStatus newStatus; - - if (eventDate.isBefore(today)) { - newStatus = EventStatus.PAST; - } else if (eventDate.isAfter(today)) { - newStatus = EventStatus.UPCOMING; - } else { - if (!now.isBefore(mediaEvent.getDate())) { - newStatus = EventStatus.ONGOING; - } else { - newStatus = EventStatus.UPCOMING; - } - } - - mediaEvent.setStatus(newStatus); + mediaEvent.setStatus(computeStatus(mediaEvent.getDate(), now)); eventsRepository.save(mediaEvent); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java index 101e760..0ab1cf9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/GalleryService.java @@ -13,8 +13,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.time.format.DateTimeFormatter; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.UUID; @Service @@ -27,81 +27,59 @@ public class GalleryService { private final EventsRepository eventsRepository; private final R2StorageService r2StorageService; - /** - * Get all approved gallery items with secure URLs for public access. - * Only approved content is returned with time-limited signed URLs. - */ public Page getAllApprovedGalleryItems(Pageable pageable) { - Page uploads = uploadRepository.findByApprovedTrueAndDeletedAtIsNull(pageable); - return uploads.map(this::convertToPublicGalleryItem); // Use secure URLs for public + return uploadRepository.findByApprovedTrueAndDeletedAtIsNull(pageable) + .map(u -> toGalleryItem(u, false)); } - /** - * Get all gallery items (admin only) with secure URLs. - * This method should only be called by admin users. - */ public Page getAllGalleryItems(Pageable pageable) { - Page uploads = uploadRepository.findByDeletedAtIsNull(pageable); - return uploads.map(this::convertToAdminGalleryItem); // Admin can see all with secure URLs + return uploadRepository.findByDeletedAtIsNull(pageable) + .map(u -> toGalleryItem(u, true)); } - /** - * Get gallery items by event with secure URLs (public access, approved only). - */ public Page getGalleryItemsByEvent(UUID eventId, Pageable pageable) { - // For public event galleries, only return approved content MediaEvent mediaEvent = eventsRepository.findById(eventId) .orElseThrow(() -> new IllegalArgumentException("Event not found")); - Page uploads = uploadRepository.findByMediaEventAndApprovedTrueAndDeletedAtIsNull(mediaEvent, pageable); - return uploads.map(this::convertToPublicGalleryItem); - } - - /** - * Convert upload to gallery item for public access (approved content only). - */ - private GalleryItemDto convertToPublicGalleryItem(Upload upload) { - if (!upload.isApproved()) { - throw new SecurityException("Cannot generate public gallery item for unapproved content"); - } - return convertToGalleryItem(upload, false); // false = not admin + return uploadRepository.findByMediaEventAndApprovedTrueAndDeletedAtIsNull(mediaEvent, pageable) + .map(u -> toGalleryItem(u, false)); } - /** - * Convert upload to gallery item for admin access (can see all content). - */ - private GalleryItemDto convertToAdminGalleryItem(Upload upload) { - return convertToGalleryItem(upload, true); // true = admin + public Page getUserGallery(UUID userId, Pageable pageable) { + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable) + .map(upload -> { + if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { + throw new SecurityException("User can only access their own uploads"); + } + GalleryItemDto item = toGalleryItem(upload, true); + // always show real name for own uploads regardless of anon flag + item.setAuthor(user.getFirstName() + " " + user.getLastName()); + return item; + }); } - /** - * Convert upload to gallery item with secure URLs. - * @param upload The upload to convert - * @param isAdmin Whether the requesting user is an admin - * @return GalleryItemDto with secure URLs - */ - private GalleryItemDto convertToGalleryItem(Upload upload, boolean isAdmin) { + private GalleryItemDto toGalleryItem(Upload upload, boolean isAdmin) { GalleryItemDto item = new GalleryItemDto(); User uploader = upload.getUploadedBy(); - - // Basic info + item.setId(upload.getUuid().toString()); - - // Generate secure URL instead of using direct R2 URL + item.setTitle(upload.getUploadDescription() != null ? upload.getUploadDescription() : "Untitled"); + item.setFeatured(upload.isFeatured()); + item.setType(upload.getContentType().toString().toLowerCase()); + try { - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String secureUrl = r2StorageService.getSecureUrl(objectKey, upload.isApproved(), isAdmin); - item.setSrc(secureUrl); + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + item.setSrc(r2StorageService.getSecureUrl(key, upload.isApproved(), isAdmin)); } catch (SecurityException e) { log.warn("Access denied for upload {}: {}", upload.getUuid(), e.getMessage()); - item.setSrc(null); // Don't provide URL if access is denied + item.setSrc(null); } catch (Exception e) { - log.error("Failed to generate secure URL for upload {}: {}", upload.getUuid(), e.getMessage()); - item.setSrc(null); // Don't provide URL if generation fails + log.error("Failed to generate URL for upload {}: {}", upload.getUuid(), e.getMessage()); + item.setSrc(null); } - - item.setTitle(upload.getUploadDescription() != null ? upload.getUploadDescription() : "Untitled"); - item.setFeatured(upload.isFeatured()); - item.setType(upload.getContentType().toString().toLowerCase()); + + item.setThumbnail(resolveSecureThumbnail(upload, isAdmin)); if (upload.isAnon()) { item.setAuthor("Anonymous"); @@ -111,58 +89,29 @@ private GalleryItemDto convertToGalleryItem(Upload upload, boolean isAdmin) { item.setAuthor("Unknown"); } - // Generate secure thumbnail using the stored thumbnail key - String thumbnail = generateSecureThumbnail(upload, isAdmin); - item.setThumbnail(thumbnail); - - String eventName = getEventName(upload.getEventId()); - item.setEvent(eventName); - - // Format date - String date = formatDate(upload); - item.setDate(date); - + item.setEvent(upload.getMediaEvent().getName()); + + item.setDate(upload.getCreatedDate() != null + ? DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.systemDefault()).format(upload.getCreatedDate()) + : "Unknown"); + return item; } - /** - * Generate secure thumbnail URL with access control. - * Uses the stored thumbnail key if available, otherwise falls back to original image. - */ - private String generateSecureThumbnail(Upload upload, boolean isAdmin) { - if (upload == null || upload.getFileUrl() == null) return null; - + private String resolveSecureThumbnail(Upload upload, boolean isAdmin) { + if (upload.getFileUrl() == null) return null; try { - // Use stored thumbnail key if available - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - return r2StorageService.getSecureThumbnailUrl(thumbnailKey, upload.isApproved(), isAdmin); + String thumbKey = upload.getThumbnailUrl(); + if (thumbKey != null && !thumbKey.isBlank()) { + return r2StorageService.getSecureThumbnailUrl(thumbKey, upload.isApproved(), isAdmin); } - - // Fallback: use original image URL as thumbnail - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - return r2StorageService.getSecureUrl(objectKey, upload.isApproved(), isAdmin); + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + return r2StorageService.getSecureUrl(key, upload.isApproved(), isAdmin); } catch (SecurityException e) { - log.warn("Access denied for thumbnail: {}", e.getMessage()); return null; } catch (Exception e) { - log.error("Failed to generate secure thumbnail for upload {}: {}", upload.getUuid(), e.getMessage()); + log.error("Failed to generate thumbnail URL for upload {}: {}", upload.getUuid(), e.getMessage()); return null; } } - - private String getEventName(UUID eventId) { - if (eventId == null) return "General"; - - return eventsRepository.findEventById(eventId) - .map(MediaEvent::getName) - .orElse("General"); - } - - private String formatDate(Upload upload) { - if (upload.getCreatedDate() != null) { - return DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.systemDefault()).format(upload.getCreatedDate()); - } - return "Unknown"; - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java b/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java new file mode 100644 index 0000000..61a803a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java @@ -0,0 +1,73 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.ImageProcessingProperties; +import com.ibrasoft.lensbridge.model.upload.Upload; +import com.ibrasoft.lensbridge.repository.upload.UploadRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ImageProcessingService { + + private final R2StorageService r2StorageService; + private final UploadRepository uploadRepository; + private final ImageProcessingProperties properties; + + @Async + public CompletableFuture generateThumbnail(Upload upload) { + try { + String objectKey = upload.getFileUrl(); + log.info("Generating thumbnail for upload {} (key: {})", upload.getUuid(), objectKey); + + byte[] originalBytes = r2StorageService.getObjectBytes(objectKey); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Thumbnails.of(new ByteArrayInputStream(originalBytes)) + .size(properties.getThumbnailWidth(), properties.getThumbnailHeight()) + .keepAspectRatio(true) + .outputQuality(properties.getThumbnailQuality()) + .outputFormat("jpg") + .toOutputStream(out); + + byte[] thumbnailBytes = out.toByteArray(); + String thumbnailKey = resolveThumbnailKey(objectKey); + + r2StorageService.putObject( + thumbnailKey, + new ByteArrayInputStream(thumbnailBytes), + thumbnailBytes.length, + "image/jpeg"); + + Optional fresh = uploadRepository.findById(upload.getUuid()); + if (fresh.isPresent()) { + fresh.get().setThumbnailUrl(thumbnailKey); + uploadRepository.save(fresh.get()); + } + + log.info("Thumbnail generated: {} -> {} ({} bytes)", objectKey, thumbnailKey, thumbnailBytes.length); + return CompletableFuture.completedFuture(thumbnailKey); + + } catch (Exception e) { + log.error("Failed to generate thumbnail for upload {}: {}", upload.getUuid(), e.getMessage(), e); + return CompletableFuture.failedFuture(e); + } + } + + private String resolveThumbnailKey(String objectKey) { + String filename = objectKey.contains("/") + ? objectKey.substring(objectKey.lastIndexOf('/') + 1) + : objectKey; + return properties.getThumbnailFolder() + filename; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java b/src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java deleted file mode 100644 index df85039..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/MediaConversionService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.exception.ImageProcessingException; -import com.ibrasoft.lensbridge.exception.VideoProcessingException; -import com.ibrasoft.lensbridge.exception.VideoTooLongException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.*; - -@Service -public class MediaConversionService { - - @Value("${uploads.video.maxduration}") - private double MAX_DURATION_SECONDS; - - private static String magickExecutable; - - @Value("${conversion.magick.executable:magick}") - public void setMagickExecutable(String magickExecutable) { - MediaConversionService.magickExecutable = magickExecutable; - } - - /** - * Converts a HEIC image to JPEG using ImageMagick. - * - * @param inputHeic the HEIC file - * @param outputJpg the desired JPEG file - * @throws IOException if process fails - * @throws InterruptedException if process is interrupted - */ - public static void convertHeicToJpg(File inputHeic, File outputJpg) throws IOException, InterruptedException { - - if (!inputHeic.exists()) { - throw new IllegalArgumentException("Input file does not exist: " + inputHeic.getAbsolutePath()); - } - - if (outputJpg.getParentFile() != null) { - outputJpg.getParentFile().mkdirs(); - } - - ProcessBuilder pb = new ProcessBuilder( - magickExecutable, - inputHeic.getAbsolutePath(), - outputJpg.getAbsolutePath() - ); - - pb.redirectErrorStream(true); - Process process = pb.start(); - - // wait for da fork - // For the CSC209 Students, this is the equivalent of waitpid ;P - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new ImageProcessingException("ImageMagick failed with exit code " + exitCode); - } - } - - - public File transcodeToHevc(InputStream inputStream, String fileName) throws IOException, InterruptedException { - File inputFile = File.createTempFile("input-", ".mp4"); - File outputFile = File.createTempFile("output-", ".mp4"); - - // Save stream to file - try (FileOutputStream fos = new FileOutputStream(inputFile)) { - inputStream.transferTo(fos); - } - - // Enforce max duration - double duration = getVideoDurationInSeconds(inputFile); - if (duration > MAX_DURATION_SECONDS) { - inputFile.delete(); - outputFile.delete(); - throw new VideoTooLongException("Video is too long! Maximum allowed is " + MAX_DURATION_SECONDS + " seconds."); - } - - ProcessBuilder pb = new ProcessBuilder( - "ffmpeg", - "-hwaccel", "cuda", // optional: GPU-assisted decoding - "-i", inputFile.getAbsolutePath(), - "-c:v", "hevc_nvenc", // GPU-based HEVC encoder (NVENC) - "-preset", "p5", // p1 = fastest, p7 = slowest (best quality) - "-cq", "28", // Constant quality (lower = better) - "-c:a", "aac", "-b:a", "128k", - "-y", - outputFile.getAbsolutePath() - ); - - Process process = pb.inheritIO().start(); - int exitCode = process.waitFor(); - - inputFile.delete(); - - if (exitCode != 0) { - outputFile.delete(); - throw new VideoProcessingException("FFmpeg failed with exit code " + exitCode); - } - - return outputFile; - } - - private double getVideoDurationInSeconds(File file) throws IOException, InterruptedException { - ProcessBuilder pb = new ProcessBuilder( - "ffprobe", "-v", "error", - "-select_streams", "v:0", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", - file.getAbsolutePath() - ); - - Process process = pb.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line = reader.readLine(); - int exitCode = process.waitFor(); - - if (exitCode != 0 || line == null) { - throw new VideoProcessingException("Failed to get video duration"); - } - - return Double.parseDouble(line.trim()); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index b284bc6..6c9ca18 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -88,7 +88,7 @@ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) String objectKey; try { String filename = generatePosterFilename(imageFile.getOriginalFilename()); - objectKey = r2StorageService.uploadImage(imageFile.getBytes(), filename); + objectKey = r2StorageService.uploadImage(filename, imageFile); log.info("Uploaded poster image to R2: {}", objectKey); } catch (IOException e) { log.error("Failed to upload poster image", e); @@ -161,7 +161,7 @@ public Poster updatePosterImage(UUID posterId, MultipartFile imageFile) { String objectKey; try { String filename = generatePosterFilename(imageFile.getOriginalFilename()); - objectKey = r2StorageService.uploadImage(imageFile.getBytes(), filename); + objectKey = r2StorageService.uploadImage(filename, imageFile); log.info("Uploaded new poster image to R2: {}", objectKey); } catch (IOException e) { log.error("Failed to upload poster image", e); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java b/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java index fad4290..03e549a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/R2StorageService.java @@ -1,28 +1,33 @@ package com.ibrasoft.lensbridge.service; -import com.ibrasoft.lensbridge.exception.ImageProcessingException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Configuration; -import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import java.io.File; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; + import java.io.IOException; +import java.io.InputStream; import java.net.URI; -import java.nio.file.Files; +import java.security.MessageDigest; import java.time.Duration; +import java.util.HexFormat; +import java.util.Map; @Service @RequiredArgsConstructor @@ -31,387 +36,344 @@ public class R2StorageService { private final S3Client s3Client; private final S3Presigner presigner; - private final MediaConversionService mediaConversionService; - - @Value("${cloudflare.r2.access-key-id}") - private String accessKeyId; - - @Value("${cloudflare.r2.secret-access-key}") - private String secretAccessKey; - - @Value("${cloudflare.r2.endpoint}") - private String endpoint; @Value("${cloudflare.r2.bucket-name}") private String bucketName; - @Value("${cloudflare.r2.public-url}") + @Value("${cloudflare.r2.public-url:}") private String publicUrl; @Value("${cloudflare.r2.url-expiration-minutes:15}") private long urlExpirationMinutes; - @PostConstruct - public void init() { - log.info("R2StorageService initialized (endpoint='{}', bucket='{}', publicUrl='{}')", endpoint, bucketName, publicUrl); - } - - private String normalizeEndpoint(String ep) { - if (ep == null) return null; - String trimmed = ep.trim(); - // Remove any trailing slash - while (trimmed.endsWith("/")) { - trimmed = trimmed.substring(0, trimmed.length() - 1); - } - return trimmed; - } - /** - * Replace the CloudFlare R2 endpoint in a presigned URL with our custom domain + * Upload an object directly to R2. + * + * This should generally only be used internally + * for backend-generated objects like thumbnails, + * transcoded media, exports, etc. + * + * User uploads should use presigned URLs instead. */ - private String replaceEndpointWithCustomDomain(String presignedUrl) { - if (publicUrl == null || publicUrl.isBlank()) { - return presignedUrl; // Return original URL if no custom domain configured - } - - try { - URI originalUri = URI.create(presignedUrl); - String originalHost = originalUri.getHost(); - String originalPath = originalUri.getPath(); - String queryString = originalUri.getQuery(); - - // Extract the custom domain from publicUrl - URI customUri = URI.create(publicUrl); - String customHost = customUri.getHost(); - String customScheme = customUri.getScheme(); - - // Remove bucket name from path if it exists - String newPath = originalPath; - if (originalPath != null && originalPath.startsWith("/" + bucketName + "/")) { - newPath = originalPath.substring(bucketName.length() + 1); // Remove "/bucketName" - } - - // Construct the new URL with custom domain - StringBuilder customUrl = new StringBuilder(); - customUrl.append(customScheme).append("://").append(customHost); - customUrl.append(newPath); - if (queryString != null && !queryString.isEmpty()) { - customUrl.append("?").append(queryString); - } - - String result = customUrl.toString(); - log.debug("Replaced CloudFlare endpoint '{}' with custom domain '{}' and removed bucket from path: '{}' -> '{}'", - originalHost, customHost, originalPath, newPath); - return result; - } catch (Exception e) { - log.error("Failed to replace endpoint with custom domain in URL: {}", presignedUrl, e); - return presignedUrl; // Return original URL on error - } - } + public void putObject( + String objectKey, + InputStream inputStream, + long contentLength, + String contentType + ) { + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentLength(contentLength) + .contentType(contentType) + .build(); + s3Client.putObject( + request, + RequestBody.fromInputStream(inputStream, contentLength) + ); - /** - * Upload an image file to R2 - */ - public String uploadImage(File imageFile, String fileName) throws IOException { - String lowerName = fileName.toLowerCase(); - if (lowerName.endsWith(".heic") || lowerName.endsWith(".heif")) { - // Convert HEIC to JPG before uploading - File jpgFile = File.createTempFile("converted-", ".jpg"); - try { - MediaConversionService.convertHeicToJpg(imageFile, jpgFile); - String jpgName = fileName.replaceAll("(?i)\\.heic$", ".jpg"); - String result = uploadFile(jpgFile, jpgName, "images/"); - jpgFile.delete(); - return result; - } catch (Exception e) { - jpgFile.delete(); - log.error("HEIC to JPG conversion failed", e); - throw new IOException("HEIC to JPG conversion failed", e); - } - } - return uploadFile(imageFile, fileName, "images/"); + log.info("Uploaded object to R2: {}", objectKey); } /** - * Upload an image from bytes to R2 + * Generate a presigned upload URL. + * + * The client uploads directly to R2 using this URL. */ - public String uploadImage(byte[] fileBytes, String fileName) throws IOException { - String lowerName = fileName.toLowerCase(); - if (lowerName.endsWith(".heic") || lowerName.endsWith(".heif")) { - // Convert HEIC bytes to JPG before uploading - File tempHeicFile = File.createTempFile("temp-heic-", ".heic"); - File jpgFile = File.createTempFile("converted-", ".jpg"); - try { - Files.write(tempHeicFile.toPath(), fileBytes); - MediaConversionService.convertHeicToJpg(tempHeicFile, jpgFile); - String jpgName = fileName.replaceAll("(?i)\\.heic$", ".jpg"); - String result = uploadFile(jpgFile, jpgName, "images/"); - tempHeicFile.delete(); - jpgFile.delete(); - return result; - } catch (Exception e) { - tempHeicFile.delete(); - jpgFile.delete(); - log.error("HEIC to JPG conversion failed", e); - throw new ImageProcessingException("HEIC to JPG conversion failed", e); - } - } - return uploadFile(fileBytes, fileName, "images/", detectContentType(fileName)); - } + public String generatePresignedUploadUrl( + String objectKey, + String contentType, + long contentLength, + Duration expiration + ) { + + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentType(contentType) + .contentLength(contentLength) + .build(); - /** - * Upload a video file to R2 - */ - public String uploadVideo(File videoFile, String fileName) throws IOException { - return uploadFile(videoFile, fileName, "videos/"); + PutObjectPresignRequest presignRequest = + PutObjectPresignRequest.builder() + .signatureDuration(expiration) + .putObjectRequest(putRequest) + .build(); + + String url = presigner + .presignPutObject(presignRequest) + .url() + .toString(); + + log.debug("Generated presigned upload URL for {}", objectKey); + + return url; } /** - * Upload a video from bytes to R2 + * Convenience overload using configured expiration. */ - public String uploadVideo(byte[] fileBytes, String fileName) throws IOException { - return uploadFile(fileBytes, fileName, "videos/", detectContentType(fileName)); + public String generatePresignedUploadUrl( + String objectKey, + String contentType, + long contentLength + ) { + + return generatePresignedUploadUrl( + objectKey, + contentType, + contentLength, + Duration.ofMinutes(urlExpirationMinutes) + ); } /** - * Upload file from File object - * Returns the object key (not a URL) since we'll generate presigned URLs on demand + * Generate a secure temporary download URL. */ - private String uploadFile(File file, String fileName, String folder) throws IOException { - String key = folder + fileName; - String contentType = Files.probeContentType(file.toPath()); - - if (contentType == null) { - contentType = detectContentType(fileName); - } + public String generatePresignedDownloadUrl( + String objectKey + ) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() + GetObjectRequest getRequest = GetObjectRequest.builder() .bucket(bucketName) - .key(key) - .contentType(contentType) + .key(objectKey) .build(); - try { - s3Client.putObject(putObjectRequest, RequestBody.fromFile(file)); - log.info("Successfully uploaded file to R2: {}", key); - // Return the object key instead of a URL - URLs will be generated on demand - return key; - } catch (Exception e) { - log.error("Failed to upload file to R2: {}", key, e); - throw e; - } + GetObjectPresignRequest presignRequest = + GetObjectPresignRequest.builder() + .signatureDuration( + Duration.ofMinutes(urlExpirationMinutes) + ) + .getObjectRequest(getRequest) + .build(); + + String url = presigner + .presignGetObject(presignRequest) + .url() + .toString(); + + return rewriteToCustomDomain(url); } /** - * Upload file from byte array - * Returns the object key (not a URL) since we'll generate presigned URLs on demand + * Delete an object. */ - private String uploadFile(byte[] fileBytes, String fileName, String folder, String contentType) throws IOException { - String key = folder + fileName; + public void deleteObject(String objectKey) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() + DeleteObjectRequest request = DeleteObjectRequest.builder() .bucket(bucketName) - .key(key) - .contentType(contentType) + .key(objectKey) .build(); - try { - s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); - log.info("Successfully uploaded file to R2: {}", key); - // Return the object key instead of a URL - URLs will be generated on demand - return key; - } catch (Exception e) { - log.error("Failed to upload file to R2: {}", key, e); - throw e; - } - } + s3Client.deleteObject(request); - /** - * Generate a secure time-limited URL for accessing content using S3 presigner. - * @param objectKey The object key (without bucket name) - * @param isApproved Whether the content is approved - * @param isAdmin Whether the requester is an admin (admins can view unapproved) - */ - public String getSecureUrl(String objectKey, boolean isApproved, boolean isAdmin) { - if (objectKey == null) { - throw new IllegalArgumentException("Object key cannot be null"); - } - if (!isApproved && !isAdmin) { - throw new SecurityException("Access denied: Content not approved for public viewing"); - } - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(urlExpirationMinutes)) - .getObjectRequest(getObjectRequest) - .build(); - String presignedUrl = presigner.presignGetObject(presignRequest).url().toString(); - - // Replace the CloudFlare endpoint with our custom domain - String customUrl = replaceEndpointWithCustomDomain(presignedUrl); - - if (log.isDebugEnabled()) { - log.debug("Generated presigned URL (adminAccess={}): key='{}' -> '{}'", isAdmin, objectKey, customUrl.split("\\?",2)[0]); - } - return customUrl; - } catch (Exception e) { - log.error("Failed to presign GET URL for {}: {}", objectKey, e.getMessage()); - throw new RuntimeException("Failed to generate secure URL", e); - } + log.info("Deleted object from R2: {}", objectKey); } /** - * Generate a secure URL for thumbnails. - * @param thumbnailKey The object key of the thumbnail (e.g., "thumbnails/uuid") - * @param isApproved Whether the content is approved - * @param isAdmin Whether the requester is an admin + * Check whether an object exists. */ - public String getSecureThumbnailUrl(String thumbnailKey, boolean isApproved, boolean isAdmin) { - if (thumbnailKey == null || thumbnailKey.isBlank()) { - return null; - } - return getSecureUrl(thumbnailKey, isApproved, isAdmin); - } + public boolean objectExists(String objectKey) { - /** - * Generate a presigned PUT URL for direct uploads (optional helper if needed later). - */ - public String generatePresignedUploadUrl(String objectKey, String contentType, String sha256hash, long contentLength) { try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() + + HeadObjectRequest request = HeadObjectRequest.builder() .bucket(bucketName) .key(objectKey) - .contentType(contentType) - .contentLength(contentLength) - // .checksumSHA256(sha256hash) .build(); - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(urlExpirationMinutes)) - .putObjectRequest(putObjectRequest) - .build(); - String presignedUrl = presigner.presignPutObject(presignRequest).url().toString(); - - // For upload URLs, we must return the original R2 endpoint, not the custom domain - // Custom domains only work for downloads, not authenticated uploads - return presignedUrl; + + s3Client.headObject(request); + + return true; + + } catch (NoSuchKeyException e) { + + return false; + } catch (Exception e) { - log.error("Failed to presign PUT URL for {}: {}", objectKey, e.getMessage()); - throw new RuntimeException("Failed to generate upload URL", e); + + log.error( + "Failed checking object existence: {}", + objectKey, + e + ); + + return false; } } /** - * Delete an object from R2 + * Retrieve object metadata. */ - public void deleteObject(String objectKey) { - try { - DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); + public R2ObjectMetadata getObjectMetadata(String objectKey) { - s3Client.deleteObject(deleteObjectRequest); - log.info("Successfully deleted object from R2: {}", objectKey); - } catch (Exception e) { - log.error("Failed to delete object from R2: {}", objectKey, e); - throw new RuntimeException("Failed to delete object from R2", e); - } + HeadObjectRequest request = HeadObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build(); + + HeadObjectResponse response = + s3Client.headObject(request); + + return new R2ObjectMetadata( + objectKey, + response.contentLength(), + response.contentType(), + response.eTag(), + response.lastModified(), + response.metadata() + ); } /** - * Extract object key from a full URL or return as-is if already a key. - * Handles both path-style (endpoint/bucket/key) and virtual-host styles. + * Extract object key from URL or return original. */ - public String extractObjectKeyFromUrl(String urlOrKey) { - if (urlOrKey == null || urlOrKey.isBlank()) { + public String extractObjectKey(String value) { + + if (value == null || value.isBlank()) { return null; } - // If not an URL assume it's an object key already - if (!urlOrKey.startsWith("http")) { - return urlOrKey; + + if (!value.startsWith("http")) { + return value; } + try { - URI uri = URI.create(urlOrKey); - String path = uri.getPath(); // e.g. /bucketName/folder/file or /folder/file (if virtual host) + + URI uri = URI.create(value); + + String path = uri.getPath(); + if (path == null || path.isBlank()) { return null; } - // Remove leading slash + if (path.startsWith("/")) { path = path.substring(1); } - // If path starts with bucketName/, strip it + if (path.startsWith(bucketName + "/")) { path = path.substring(bucketName.length() + 1); } + return path; + } catch (Exception e) { - log.error("Error extracting object key from URL: {}", urlOrKey, e); + + log.error("Failed extracting object key", e); + return null; } } /** - * TEMP retained methods for compatibility; now return presigned URLs. + * Replace R2 endpoint with public CDN/custom domain. */ - public String generateImageThumbnail(String objectKey) { - return getSecureUrl(objectKey, true, true); + private String rewriteToCustomDomain(String originalUrl) { + + if (publicUrl == null || publicUrl.isBlank()) { + return originalUrl; + } + + try { + + URI originalUri = URI.create(originalUrl); + URI publicUri = URI.create(publicUrl); + + String path = originalUri.getPath(); + + if (path.startsWith("/" + bucketName + "/")) { + path = path.substring(bucketName.length() + 1); + } + + StringBuilder rewritten = new StringBuilder(); + + rewritten.append(publicUri.getScheme()) + .append("://") + .append(publicUri.getHost()) + .append(path); + + if (originalUri.getQuery() != null) { + rewritten.append("?") + .append(originalUri.getQuery()); + } + + return rewritten.toString(); + + } catch (Exception e) { + + log.error( + "Failed rewriting presigned URL to custom domain", + e + ); + + return originalUrl; + } } - public String generateVideoThumbnail(String objectKey) { - return getSecureUrl(objectKey, true, true); + /** + * Upload an image file from a MultipartFile directly to R2. + * Used for backend-initiated uploads (e.g. poster images). + */ + public String uploadImage(String key, MultipartFile file) throws IOException { + putObject(key, file.getInputStream(), file.getSize(), file.getContentType()); + return key; } /** - * Detect content type based on file extension + * Download object bytes from R2. */ - private String detectContentType(String fileName) { - String lowerCaseFileName = fileName.toLowerCase(); - - // Images - if (lowerCaseFileName.endsWith(".jpg") || lowerCaseFileName.endsWith(".jpeg")) { - return "image/jpeg"; - } else if (lowerCaseFileName.endsWith(".png")) { - return "image/png"; - } else if (lowerCaseFileName.endsWith(".heic")) { - return "image/heic"; + public byte[] getObjectBytes(String objectKey) throws IOException { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build(); + try (ResponseInputStream stream = s3Client.getObject(getRequest)) { + return stream.readAllBytes(); } - - // Videos - else if (lowerCaseFileName.endsWith(".mp4")) { - return "video/mp4"; - } else if (lowerCaseFileName.endsWith(".mov")) { - return "video/quicktime"; - } else if (lowerCaseFileName.endsWith(".mkv")) { - return "video/x-matroska"; - } else if (lowerCaseFileName.endsWith(".avi")) { - return "video/x-msvideo"; + } + + /** + * Compute the SHA-256 hex digest of a stored object. + */ + public String calculateSha256Hash(String objectKey) throws Exception { + byte[] bytes = getObjectBytes(objectKey); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(bytes)); + } + + /** + * Generate a presigned download URL for an object, with access control. + * Throws SecurityException if the content is unapproved and the caller is not admin. + */ + public String getSecureUrl(String objectKey, boolean approved, boolean isAdmin) { + if (!approved && !isAdmin) { + throw new SecurityException("Access denied: content is not approved"); } - - return "application/octet-stream"; + return generatePresignedDownloadUrl(objectKey); } /** - * Check if object exists in R2 + * Generate a presigned download URL for a thumbnail, with access control. */ - public boolean objectExists(String objectKey) { - try { - HeadObjectRequest headObjectRequest = HeadObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - - s3Client.headObject(headObjectRequest); - return true; - } catch (NoSuchKeyException e) { - return false; - } catch (Exception e) { - log.error("Error checking if object exists: {}", objectKey, e); - return false; + public String getSecureThumbnailUrl(String thumbnailKey, boolean approved, boolean isAdmin) { + if (!approved && !isAdmin) { + throw new SecurityException("Access denied: content is not approved"); } + return generatePresignedDownloadUrl(thumbnailKey); } -} + + /** + * Lightweight immutable metadata DTO. + */ + public record R2ObjectMetadata( + String objectKey, + long contentLength, + String contentType, + String etag, + java.time.Instant lastModified, + Map metadata + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java index 7044fbb..ea347e3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/RefreshTokenService.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.service; +import com.ibrasoft.lensbridge.exception.RefreshTokenException; import com.ibrasoft.lensbridge.model.auth.RefreshToken; import com.ibrasoft.lensbridge.repository.auth.RefreshTokenRepository; import com.ibrasoft.lensbridge.repository.auth.UserRepository; @@ -7,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -88,12 +90,15 @@ public Optional findByToken(String token) { * Verify refresh token is valid and not expired */ public RefreshToken verifyExpiration(RefreshToken token) { - if (token.isExpired() || token.isRevoked()) { + if (token.isRevoked()) { refreshTokenRepository.delete(token); - throw new RuntimeException("Refresh token was expired or revoked. Please make a new signin request"); + throw new RefreshTokenException("Refresh token revoked. Please login again.", HttpStatus.UNAUTHORIZED); } - - // Update last used date + if (token.isExpired()) { + refreshTokenRepository.delete(token); + throw new RefreshTokenException("Refresh token expired. Please login again.", HttpStatus.UNAUTHORIZED); + } + token.setLastUsedDate(Instant.now()); return refreshTokenRepository.save(token); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java deleted file mode 100644 index 8e8984f..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailBackfillService.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.model.upload.UploadType; -import com.ibrasoft.lensbridge.repository.upload.UploadRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * One-time backfill script to generate thumbnails for existing uploads. - * - * To run this backfill, add the following to application.properties: - * thumbnail.backfill.enabled=true - * - * Then start the application. After completion, remove the property or set to false. - * The application will continue running normally after backfill completes. - */ -@Component -@ConditionalOnProperty(name = "thumbnail.backfill.enabled", havingValue = "true") -@RequiredArgsConstructor -@Slf4j -public class ThumbnailBackfillService implements CommandLineRunner { - - private final UploadRepository uploadRepository; - private final ThumbnailService thumbnailService; - - @Override - public void run(String... args) { - log.info("=".repeat(60)); - log.info("THUMBNAIL BACKFILL: Starting thumbnail generation for existing uploads"); - log.info("=".repeat(60)); - - // Find all image uploads without thumbnails - List uploadsWithoutThumbnails = uploadRepository.findAll().stream() - .filter(u -> u.getContentType() == UploadType.IMAGE) - .filter(u -> u.getThumbnailUrl() == null || u.getThumbnailUrl().isBlank()) - .filter(u -> u.getFileUrl() != null && !u.getFileUrl().isBlank()) - .toList(); - - int total = uploadsWithoutThumbnails.size(); - log.info("Found {} image uploads without thumbnails", total); - - if (total == 0) { - log.info("No uploads need thumbnail backfill. Exiting backfill."); - return; - } - - AtomicInteger success = new AtomicInteger(0); - AtomicInteger failed = new AtomicInteger(0); - - for (int i = 0; i < uploadsWithoutThumbnails.size(); i++) { - Upload upload = uploadsWithoutThumbnails.get(i); - int current = i + 1; - - try { - log.info("[{}/{}] Processing upload: {} (key: {})", - current, total, upload.getUuid(), upload.getFileUrl()); - - // Generate thumbnail synchronously (blocking) - String thumbnailKey = thumbnailService.generateThumbnail(upload.getFileUrl()); - - // Update the upload record - upload.setThumbnailUrl(thumbnailKey); - uploadRepository.save(upload); - - success.incrementAndGet(); - log.info("[{}/{}] SUCCESS: Generated thumbnail: {}", current, total, thumbnailKey); - - } catch (Exception e) { - failed.incrementAndGet(); - log.error("[{}/{}] FAILED: Upload {} - {}", - current, total, upload.getUuid(), e.getMessage()); - } - - // Progress update every 10 items - if (current % 10 == 0) { - log.info("Progress: {}/{} processed ({} success, {} failed)", - current, total, success.get(), failed.get()); - } - } - - log.info("=".repeat(60)); - log.info("THUMBNAIL BACKFILL COMPLETE"); - log.info("Total processed: {}", total); - log.info("Successful: {}", success.get()); - log.info("Failed: {}", failed.get()); - log.info("=".repeat(60)); - log.info("You can now set 'thumbnail.backfill.enabled=false' and restart."); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java b/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java deleted file mode 100644 index 11bbcb3..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/ThumbnailService.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.ibrasoft.lensbridge.service; - -import com.ibrasoft.lensbridge.model.upload.Upload; -import com.ibrasoft.lensbridge.repository.upload.UploadRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import net.coobird.thumbnailator.Thumbnails; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import software.amazon.awssdk.core.ResponseInputStream; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectResponse; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.util.Optional; -import java.util.UUID; - -/** - * Service for generating and managing image thumbnails. - * Thumbnails are generated asynchronously after image uploads complete. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class ThumbnailService { - - private final S3Client s3Client; - private final UploadRepository uploadRepository; - - @Value("${cloudflare.r2.bucket-name}") - private String bucketName; - - @Value("${thumbnail.width:400}") - private int thumbnailWidth; - - @Value("${thumbnail.height:400}") - private int thumbnailHeight; - - @Value("${thumbnail.quality:0.85}") - private double thumbnailQuality; - - @Value("${thumbnail.folder:thumbnails/}") - private String thumbnailFolder; - - /** - * Generate a thumbnail for an uploaded image asynchronously. - * The thumbnail is stored in the thumbnails/ folder with the same filename. - * Updates the Upload entity with the thumbnail URL after generation. - * - * @param uploadId The UUID of the upload to generate thumbnail for - * @param objectKey The R2 object key of the original image (e.g., "images/uuid") - */ - @Async - public void generateThumbnailAsync(UUID uploadId, String objectKey) { - try { - log.info("Starting thumbnail generation for upload {} (key: {})", uploadId, objectKey); - String thumbnailKey = generateThumbnail(objectKey); - - // Update the upload entity with the thumbnail URL - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setThumbnailUrl(thumbnailKey); - uploadRepository.save(upload); - log.info("Successfully updated upload {} with thumbnail: {}", uploadId, thumbnailKey); - } else { - log.warn("Upload {} not found when trying to update thumbnail URL", uploadId); - } - } catch (Exception e) { - log.error("Failed to generate thumbnail for upload {} (key: {}): {}", - uploadId, objectKey, e.getMessage(), e); - } - } - - /** - * Generate a thumbnail for an image stored in R2. - * - * @param objectKey The R2 object key of the original image - * @return The R2 object key of the generated thumbnail - */ - public String generateThumbnail(String objectKey) throws Exception { - // Download the original image from R2 - GetObjectRequest getRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(objectKey) - .build(); - - byte[] thumbnailBytes; - try (ResponseInputStream objectStream = s3Client.getObject(getRequest)) { - // Generate thumbnail using Thumbnailator - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - Thumbnails.of(objectStream) - .size(thumbnailWidth, thumbnailHeight) - .keepAspectRatio(true) - .outputQuality(thumbnailQuality) - .outputFormat("jpg") - .toOutputStream(outputStream); - - thumbnailBytes = outputStream.toByteArray(); - } - - // Generate thumbnail key: thumbnails/ - // e.g., images/uuid -> thumbnails/uuid - String filename = extractFilename(objectKey); - String thumbnailKey = thumbnailFolder + filename; - - // Upload thumbnail to R2 - PutObjectRequest putRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(thumbnailKey) - .contentType("image/jpeg") - .contentLength((long) thumbnailBytes.length) - .build(); - - s3Client.putObject(putRequest, RequestBody.fromBytes(thumbnailBytes)); - - log.info("Generated thumbnail: {} -> {} ({}x{}, {} bytes)", - objectKey, thumbnailKey, thumbnailWidth, thumbnailHeight, thumbnailBytes.length); - - return thumbnailKey; - } - - /** - * Extract the filename from an object key. - * e.g., "images/abc-123" -> "abc-123" - */ - private String extractFilename(String objectKey) { - if (objectKey == null) { - return null; - } - int lastSlash = objectKey.lastIndexOf('/'); - return lastSlash >= 0 ? objectKey.substring(lastSlash + 1) : objectKey; - } - - /** - * Check if a thumbnail exists for the given object key. - */ - public boolean thumbnailExists(String objectKey) { - String filename = extractFilename(objectKey); - String thumbnailKey = thumbnailFolder + filename; - - try { - s3Client.headObject(builder -> builder.bucket(bucketName).key(thumbnailKey)); - return true; - } catch (Exception e) { - return false; - } - } - - /** - * Get the thumbnail key for a given original object key. - */ - public String getThumbnailKey(String objectKey) { - String filename = extractFilename(objectKey); - return thumbnailFolder + filename; - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java new file mode 100644 index 0000000..b9618ad --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java @@ -0,0 +1,75 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.UploadProperties; +import com.ibrasoft.lensbridge.dto.upload.response.UploadLimitsResponse; +import com.ibrasoft.lensbridge.exception.DailyLimitExceededException; +import com.ibrasoft.lensbridge.exception.FileSizeLimitExceededException; +import com.ibrasoft.lensbridge.exception.InvalidContentTypeException; +import com.ibrasoft.lensbridge.model.auth.Role; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.util.unit.DataSize; + +import java.util.Comparator; +import java.util.Objects; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UploadLimitsService { + + private final UploadProperties uploadProperties; + private final UploadService uploadService; + + public Role getHighestRole(Authentication authentication) { + if (authentication == null || authentication.getAuthorities() == null) { + return Role.USER; + } + return authentication.getAuthorities().stream() + .map(a -> { + String name = a.getAuthority(); + if (name.startsWith("ROLE_")) name = name.substring(5); + try { return Role.valueOf(name); } + catch (IllegalArgumentException e) { return null; } + }) + .filter(Objects::nonNull) + .max(Comparator.comparingInt(Enum::ordinal)) + .orElse(Role.USER); + } + + public void validateUpload(UUID userId, Role role, long fileSize, String contentType) { + if (!uploadProperties.getAllowedFileTypes().contains(contentType)) { + throw new InvalidContentTypeException(contentType); + } + + DataSize maxAllowed = uploadProperties.getMaxSizeForRole(role.name().toLowerCase()); + if (fileSize > maxAllowed.toBytes()) { + throw new FileSizeLimitExceededException(maxAllowed.toBytes(), fileSize); + } + + int dailyLimit = uploadProperties.getDailyLimitForRole(role.name().toLowerCase()); + if (uploadService.hasReachedDailyLimit(userId, dailyLimit)) { + long count = uploadService.countUploadsToday(userId); + throw new DailyLimitExceededException(dailyLimit, count); + } + } + + public UploadLimitsResponse getLimitsForRole(Role role, UUID userId) { + String roleKey = role.name().toLowerCase(); + DataSize maxSize = uploadProperties.getMaxSizeForRole(roleKey); + int dailyLimit = uploadProperties.getDailyLimitForRole(roleKey); + long uploadsToday = uploadService.countUploadsToday(userId); + + return UploadLimitsResponse.builder() + .role(roleKey) + .maxSizeBytes(maxSize.toBytes()) + .maxSizeMB(maxSize.toMegabytes()) + .allowedContentTypes(uploadProperties.getAllowedFileTypes()) + .dailyLimit(dailyLimit) + .uploadsToday(uploadsToday) + .uploadsRemaining(Math.max(0, dailyLimit - uploadsToday)) + .build(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index 38bcb57..412fdff 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -2,7 +2,6 @@ import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; -import com.ibrasoft.lensbridge.dto.upload.response.GalleryItemDto; import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.MediaEvent; @@ -16,6 +15,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; + import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; @@ -38,99 +38,12 @@ public class UploadService { @Value("${uploads.default-featured:false}") private boolean defaultFeatured; - public void approveUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setApproved(true); - uploadRepository.save(upload); - log.info("Upload {} approved successfully", uploadId); - } else { - log.warn("Attempted to approve non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - public void featureUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setFeatured(true); - uploadRepository.save(upload); - log.info("Upload {} featured successfully", uploadId); - } else { - log.warn("Attempted to feature non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - public void unfeatureUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setFeatured(false); - uploadRepository.save(upload); - log.info("Upload {} unfeatured successfully", uploadId); - } else { - log.warn("Attempted to unfeature non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - public void unapproveUpload(UUID uploadId) { - Optional uploadOpt = uploadRepository.findById(uploadId); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - upload.setApproved(false); - uploadRepository.save(upload); - log.info("Upload {} unapproved successfully", uploadId); - } else { - log.warn("Attempted to unapprove non-existent upload: {}", uploadId); - throw new IllegalArgumentException("Upload not found"); - } - } - - /** - * Count uploads for a user today - */ - public long countUploadsToday(UUID userId) { - LocalDate today = LocalDate.now(); - Instant startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - User user = userService.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - - return uploadRepository.countByUploadedByAndCreatedDateBetweenAndDeletedAtIsNull( - user, - startOfDay, - endOfDay); - } - - /** - * Check if user has reached their daily upload limit - */ - public boolean hasReachedDailyLimit(UUID userId, int dailyLimit) { - return countUploadsToday(userId) >= dailyLimit; - } + // ── Entity creation ─────────────────────────────────────────────────────── - /** - * Create an Upload entity for a file that has been directly uploaded to R2. - * This method is used when files are uploaded via presigned URLs. - */ - public Upload createDirectUpload(String objectKey, String fileName, String contentType, + public Upload createUpload(String objectKey, String fileName, UUID eventId, String description, String instagramHandle, boolean anon, UUID uploadedBy) { try { - // Determine upload type from content type - UploadType uploadType; - if (contentType != null && contentType.startsWith("image")) { - uploadType = UploadType.IMAGE; - } else if (contentType != null && contentType.startsWith("video")) { - uploadType = UploadType.VIDEO; - } else { - uploadType = UploadType.IMAGE; // Default fallback - } - User user = userService.findById(uploadedBy) .orElseThrow(() -> new IllegalArgumentException("User not found")); MediaEvent mediaEvent = eventsService.getEventById(eventId) @@ -148,125 +61,104 @@ public Upload createDirectUpload(String objectKey, String fileName, String conte upload.setApproved(defaultApproved); upload.setFeatured(defaultFeatured); upload.setAnon(anon); - upload.setContentType(uploadType); + upload.setContentType(UploadType.IMAGE); uploadRepository.save(upload); - log.info("Created direct upload record: {} for object: {}", upload.getUuid(), objectKey); + log.info("Created upload record: {} for object: {}", upload.getUuid(), objectKey); return upload; - } catch (Exception e) { - log.error("Failed to create direct upload record for object: {}", objectKey, e); - throw new FileProcessingException("Failed to create upload record for direct upload"); + log.error("Failed to create upload record for object: {}", objectKey, e); + throw new FileProcessingException("Failed to create upload record"); } } - public Page getAllUploads(Pageable pageable) { - return uploadRepository.findByDeletedAtIsNull(pageable); + // ── Status mutations ────────────────────────────────────────────────────── + + public void approveUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setApproved(true); + uploadRepository.save(upload); + log.info("Upload {} approved", uploadId); } - public Page getUploadsByEvent(UUID eventId, Pageable pageable) { - MediaEvent mediaEvent = eventsService.getEventById(eventId) - .orElseThrow(() -> new IllegalArgumentException("Event not found")); - return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable); + public void unapproveUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setApproved(false); + uploadRepository.save(upload); + log.info("Upload {} unapproved", uploadId); } - public Optional getUploadById(UUID id) { - return uploadRepository.findById(id); + public void featureUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setFeatured(true); + uploadRepository.save(upload); + log.info("Upload {} featured", uploadId); } - public Upload updateUpload(Upload upload) { - return uploadRepository.save(upload); + public void unfeatureUpload(UUID uploadId) { + Upload upload = findRequiredById(uploadId); + upload.setFeatured(false); + uploadRepository.save(upload); + log.info("Upload {} unfeatured", uploadId); } + // ── Deletion ────────────────────────────────────────────────────────────── + public void deleteUpload(UUID id) { Optional uploadOpt = uploadRepository.findById(id); - if (uploadOpt.isPresent()) { - Upload upload = uploadOpt.get(); - try { - // Delete original file from R2 storage - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - if (objectKey != null) { - r2StorageService.deleteObject(objectKey); - } - // Delete thumbnail from R2 storage if exists - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - r2StorageService.deleteObject(thumbnailKey); - } - } catch (Exception e) { - log.warn("Failed to delete file from R2 storage for upload {}: {}", id, e.getMessage()); - // Continue with database deletion even if R2 deletion fails - } - } uploadOpt.ifPresent(upload -> { + deleteFromStorage(upload); upload.setDeletedAt(Instant.now()); uploadRepository.save(upload); }); - } - - /** - * Delete user's own upload with ownership validation - */ - public void deleteUserUpload(UUID uploadId, UUID userId) { - log.info("User {} attempting to delete upload {}", userId, uploadId); - - Optional uploadOpt = uploadRepository.findById(uploadId); if (uploadOpt.isEmpty()) { + log.warn("Attempted to delete non-existent upload: {}", id); throw new IllegalArgumentException("Upload not found"); } + } + + public void deleteUserUpload(UUID uploadId, UUID userId) { + log.info("User {} deleting upload {}", userId, uploadId); - Upload upload = uploadOpt.get(); + Upload upload = uploadRepository.findById(uploadId) + .orElseThrow(() -> new IllegalArgumentException("Upload not found")); - // Verify ownership if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { throw new SecurityException("You can only delete your own uploads"); } - try { - // Delete original file from R2 storage - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - if (objectKey != null) { - r2StorageService.deleteObject(objectKey); - } - // Delete thumbnail from R2 storage if exists - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - r2StorageService.deleteObject(thumbnailKey); - } - } catch (Exception e) { - log.warn("Failed to delete file from R2 storage for upload {}: {}", uploadId, e.getMessage()); - // Continue with database deletion even if R2 deletion fails - } - + deleteFromStorage(upload); upload.setDeletedAt(Instant.now()); userService.findById(userId).ifPresent(upload::setDeletedBy); uploadRepository.save(upload); - log.info("Upload {} deleted successfully by user {}", uploadId, userId); + log.info("Upload {} deleted by user {}", uploadId, userId); } - /** - * Get all uploads with user information populated for admin interface. - * This method fetches uploads and includes the uploader's name and details. - */ - public Page getAllUploadsForAdmin(Pageable pageable) { - Page uploads = uploadRepository.findByDeletedAtIsNull(pageable); - return uploads.map(this::convertToAdminUploadDto); + private void deleteFromStorage(Upload upload) { + try { + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + if (key != null) r2StorageService.deleteObject(key); + String thumbKey = upload.getThumbnailUrl(); + if (thumbKey != null && !thumbKey.isBlank()) r2StorageService.deleteObject(thumbKey); + } catch (Exception e) { + log.warn("Failed to delete R2 objects for upload {}: {}", upload.getUuid(), e.getMessage()); + } } - /** - * Get uploads by approval status with user information for admin interface. - */ - public Page getUploadsByApprovalStatus(boolean approved, Pageable pageable) { - Page uploads = uploadRepository.findByApprovedAndDeletedAtIsNull(approved, pageable); - return uploads.map(this::convertToAdminUploadDto); + // ── Queries ─────────────────────────────────────────────────────────────── + + public Optional getUploadById(UUID id) { + return uploadRepository.findById(id); } - /** - * Get uploads by featured status with user information for admin interface. - */ - public Page getUploadsByFeaturedStatus(boolean featured, Pageable pageable) { - Page uploads = uploadRepository.findByFeaturedAndDeletedAtIsNull(featured, pageable); - return uploads.map(this::convertToAdminUploadDto); + public Page getAllUploads(Pageable pageable) { + return uploadRepository.findByDeletedAtIsNull(pageable); + } + + public Page getUploadsByEvent(UUID eventId, Pageable pageable) { + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable); } public Page getUploadsByUploadedBy(UUID userId, Pageable pageable) { @@ -275,163 +167,88 @@ public Page getUploadsByUploadedBy(UUID userId, Pageable pageable) { return uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable); } - /** - * Get user upload statistics - */ - public UserStatsResponse getUserStats(UUID userId) { - User user = userService.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - long totalUploads = uploadRepository.countByUploadedByAndDeletedAtIsNull(user); - long approvedUploads = uploadRepository.countByUploadedByAndApprovedAndDeletedAtIsNull(user, true); - long featuredUploads = uploadRepository.countByUploadedByAndFeaturedAndDeletedAtIsNull(user, true); - long pendingUploads = totalUploads - approvedUploads; - - return new UserStatsResponse( - (int) totalUploads, - (int) approvedUploads, - (int) featuredUploads, - (int) pendingUploads); + public Page getAllUploadsForAdmin(Pageable pageable) { + return uploadRepository.findByDeletedAtIsNull(pageable).map(this::toAdminDto); } - /** - * Get user uploads as GalleryItemDTOs (user can see their own uploads - * regardless of approval status) - */ - public Page getUserUploadsAsGalleryItems(UUID userId, Pageable pageable) { - User user = userService.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - Page uploads = uploadRepository.findByUploadedByAndDeletedAtIsNull(user, pageable); - return uploads.map(upload -> convertToUserGalleryItem(upload, userId)); + public Page getUploadsByApprovalStatus(boolean approved, Pageable pageable) { + return uploadRepository.findByApprovedAndDeletedAtIsNull(approved, pageable).map(this::toAdminDto); } - /** - * Convert Upload to GalleryItemDto for user's own uploads (can see all their - * own content) - */ - private GalleryItemDto convertToUserGalleryItem(Upload upload, UUID userId) { - // Verify the user owns this upload - if (upload.getUploadedBy() == null || !upload.getUploadedBy().getId().equals(userId)) { - throw new SecurityException("User can only access their own uploads"); - } - - GalleryItemDto item = new GalleryItemDto(); - - // Basic info - item.setId(upload.getUuid().toString()); - item.setTitle(upload.getUploadDescription() != null ? upload.getUploadDescription() : "Untitled"); - item.setFeatured(upload.isFeatured()); - item.setType(upload.getContentType().toString().toLowerCase()); + public Page getUploadsByFeaturedStatus(boolean featured, Pageable pageable) { + return uploadRepository.findByFeaturedAndDeletedAtIsNull(featured, pageable).map(this::toAdminDto); + } - // Generate secure URL (user can see their own content regardless of approval) - try { - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String secureUrl = r2StorageService.getSecureUrl(objectKey, true, false); // true for approved access, false - // for not admin - item.setSrc(secureUrl); - } catch (Exception e) { - log.error("Failed to generate secure URL for user upload {}: {}", upload.getUuid(), e.getMessage()); - item.setSrc(null); - } + // ── Stats ───────────────────────────────────────────────────────────────── - // Generate secure thumbnail - try { - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String thumbnailUrl = r2StorageService.getSecureThumbnailUrl(objectKey, true, false); - item.setThumbnail(thumbnailUrl); - } catch (Exception e) { - log.error("Failed to generate thumbnail for user upload {}: {}", upload.getUuid(), e.getMessage()); - item.setThumbnail(null); - } + public long countUploadsToday(UUID userId) { + LocalDate today = LocalDate.now(); + Instant startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return uploadRepository.countByUploadedByAndCreatedDateBetweenAndDeletedAtIsNull(user, startOfDay, endOfDay); + } - // Set author (always the user's name for their own uploads, even if marked - // anonymous) - Optional userOpt = userService.findById(userId); - if (userOpt.isPresent()) { - User user = userOpt.get(); - item.setAuthor(user.getFirstName() + " " + user.getLastName()); - } else { - item.setAuthor("You"); - } + public boolean hasReachedDailyLimit(UUID userId, int dailyLimit) { + return countUploadsToday(userId) >= dailyLimit; + } - // Event information - if (upload.getEventId() != null) { - Optional eventOpt = eventsService.getEventById(upload.getEventId()); - item.setEvent(eventOpt.map(MediaEvent::getName).orElse("General")); - } else { - item.setEvent("General"); - } + public UserStatsResponse getUserStats(UUID userId) { + User user = userService.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + long total = uploadRepository.countByUploadedByAndDeletedAtIsNull(user); + long approved = uploadRepository.countByUploadedByAndApprovedAndDeletedAtIsNull(user, true); + long featured = uploadRepository.countByUploadedByAndFeaturedAndDeletedAtIsNull(user, true); + return new UserStatsResponse((int) total, (int) approved, (int) featured, (int) (total - approved)); + } - // Format date - if (upload.getCreatedDate() != null) { - item.setDate(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE - .withZone(ZoneId.systemDefault()) - .format(upload.getCreatedDate())); - } else { - item.setDate("Unknown"); - } + // ── Internal helpers ────────────────────────────────────────────────────── - return item; + private Upload findRequiredById(UUID id) { + return uploadRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Upload not found: " + id)); } - /** - * Convert Upload entity to AdminUploadDto with user information and secure URLs - * populated. - * Generates time-limited signed URLs for admin access. - */ - private AdminUploadDto convertToAdminUploadDto(Upload upload) { + private AdminUploadDto toAdminDto(Upload upload) { AdminUploadDto dto = new AdminUploadDto(); - - // Copy upload fields dto.setUuid(upload.getUuid()); dto.setFileName(upload.getFileName()); - dto.setFileUrl(upload.getFileUrl()); // Keep original URL for internal reference + dto.setFileUrl(upload.getFileUrl()); dto.setUploadDescription(upload.getUploadDescription()); dto.setInstagramHandle(upload.getInstagramHandle()); dto.setUploadedBy(upload.getUploadedBy() != null ? upload.getUploadedBy().getId() : null); - dto.setEventId(upload.getEventId()); + dto.setEventId(upload.getMediaEvent() != null ? upload.getMediaEvent().getId() : null); dto.setCreatedDate(upload.getCreatedDate()); dto.setApproved(upload.isApproved()); dto.setFeatured(upload.isFeatured()); dto.setAnon(upload.isAnon()); dto.setContentType(upload.getContentType()); - // Generate secure URLs for admin access try { - // Admins can view both approved and unapproved content - String objectKey = r2StorageService.extractObjectKeyFromUrl(upload.getFileUrl()); - String secureUrl = r2StorageService.getSecureUrl(objectKey, upload.isApproved(), true); - dto.setSecureUrl(secureUrl); - - // Generate secure thumbnail URL using the stored thumbnail key - String thumbnailKey = upload.getThumbnailUrl(); - if (thumbnailKey != null && !thumbnailKey.isBlank()) { - String thumbnailUrl = r2StorageService.getSecureThumbnailUrl(thumbnailKey, upload.isApproved(), true); - dto.setThumbnailUrl(thumbnailUrl); - } else { - // Fallback: use original image URL as thumbnail if no thumbnail generated yet - dto.setThumbnailUrl(secureUrl); - } - + String key = r2StorageService.extractObjectKey(upload.getFileUrl()); + dto.setSecureUrl(r2StorageService.getSecureUrl(key, upload.isApproved(), true)); + String thumbKey = upload.getThumbnailUrl(); + String thumbUrl = (thumbKey != null && !thumbKey.isBlank()) + ? r2StorageService.getSecureThumbnailUrl(thumbKey, upload.isApproved(), true) + : dto.getSecureUrl(); + dto.setThumbnailUrl(thumbUrl); } catch (Exception e) { - // If secure URL generation fails, log error but don't break the DTO creation log.error("Failed to generate secure URLs for upload {}: {}", upload.getUuid(), e.getMessage()); dto.setSecureUrl(null); dto.setThumbnailUrl(null); } - Optional userOpt = Optional.ofNullable(upload.getUploadedBy()); - if (userOpt.isPresent()) { - User user = userOpt.get(); - dto.setUploaderFirstName(user.getFirstName()); - dto.setUploaderLastName(user.getLastName()); - dto.setUploaderEmail(user.getEmail()); - dto.setUploaderStudentNumber(user.getStudentNumber()); + if (upload.getUploadedBy() != null) { + User u = upload.getUploadedBy(); + dto.setUploaderFirstName(u.getFirstName()); + dto.setUploaderLastName(u.getLastName()); + dto.setUploaderEmail(u.getEmail()); + dto.setUploaderStudentNumber(u.getStudentNumber()); } - // Fetch and populate event information - if (upload.getEventId() != null) { - Optional eventOpt = eventsService.getEventById(upload.getEventId()); - eventOpt.ifPresent(event -> dto.setEventName(event.getName())); + if (upload.getMediaEvent() != null) { + dto.setEventName(upload.getMediaEvent().getName()); } return dto; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java new file mode 100644 index 0000000..f5ca9d4 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java @@ -0,0 +1,131 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.exception.EventNotAcceptingUploadsException; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.upload.Upload; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UploadWorkflowService { + + private final UploadService uploadService; + private final R2StorageService r2StorageService; + private final UploadLimitsService uploadLimitsService; + private final EventsService eventsService; + private final ImageProcessingService imageProcessingService; + + public PresignedUploadResponse initiateUpload( + UUID eventId, + String filename, + String contentType, + long fileSize, + String expectedSha256, + UUID userId, + Role role) { + + if (!eventsService.isEventAcceptingUploads(eventId)) { + throw new EventNotAcceptingUploadsException(eventId); + } + + uploadLimitsService.validateUpload(userId, role, fileSize, contentType); + + String objectKey = "images/" + UUID.randomUUID(); + String presignedUrl = r2StorageService.generatePresignedUploadUrl(objectKey, contentType, fileSize); + + PresignedUploadResponse.PresignedUploadResponseBuilder builder = PresignedUploadResponse.builder() + .uploadUrl(presignedUrl) + .objectKey(objectKey) + .eventId(eventId) + .method("PUT") + .contentType(contentType) + .expiresInMinutes(15); + + if (expectedSha256 != null) { + builder.expectedSha256(expectedSha256); + } + + log.info("Presigned upload initiated for event {}, role {}, file: {}, size: {}MB", + eventId, role, filename, fileSize / 1024 / 1024); + + return builder.build(); + } + + public UploadCompletionResponse completeUpload( + UUID eventId, + String objectKey, + String filename, + String contentType, + long fileSize, + String instagramHandle, + String description, + boolean anon, + String expectedSha256, + UUID userId) { + + if (!r2StorageService.objectExists(objectKey)) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File not found in storage: " + objectKey), + "File not found in storage"); + } + + verifyIntegrity(objectKey, fileSize, expectedSha256); + + Upload upload = uploadService.createUpload( + objectKey, filename, eventId, description, instagramHandle, anon, userId); + + imageProcessingService.generateThumbnail(upload); + + log.info("Upload completed for event {}: {}", eventId, upload.getUuid()); + + return UploadCompletionResponse.builder() + .uploadId(upload.getUuid()) + .objectKey(objectKey) + .eventId(eventId) + .verified(true) + .fileSize(fileSize) + .build(); + } + + private void verifyIntegrity(String objectKey, long expectedSize, String expectedSha256) { + try { + long storedSize = r2StorageService.getObjectMetadata(objectKey).contentLength(); + if (storedSize != expectedSize) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File size mismatch: expected " + expectedSize + " bytes, got " + storedSize), + "File size mismatch"); + } + + if (expectedSha256 != null) { + String actualHash = r2StorageService.calculateSha256Hash(objectKey); + if (!actualHash.equalsIgnoreCase(expectedSha256)) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File integrity check failed"), + "SHA-256 mismatch"); + } + } + } catch (ApiResponseException e) { + throw e; + } catch (Exception e) { + log.warn("Integrity verification failed for {}: {}", objectKey, e.getMessage()); + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("Failed to verify file integrity"), + "Integrity verification error"); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index f6836c8..fba0918 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.service; +import java.time.Instant; import java.util.HashSet; import java.util.Optional; import java.util.UUID; @@ -15,6 +16,7 @@ import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; import com.ibrasoft.lensbridge.repository.auth.UserRepository; import com.ibrasoft.lensbridge.security.services.EmailService; @@ -25,293 +27,163 @@ @RequiredArgsConstructor @Slf4j public class UserService { - + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; - + private final VerificationTokenService verificationTokenService; + private final RefreshTokenService refreshTokenService; + @Value("${frontend.baseurl}") private String frontendBaseUrl; - /** - * Get all users with pagination - */ public Page getAllUsers(Pageable pageable) { - log.info("Fetching all users with pagination: {}", pageable); return userRepository.findAll(pageable); } - - /** - * Find user by ID - */ + public Optional findById(UUID id) { - log.debug("Finding user by ID: {}", id); return userRepository.findById(id); } - - /** - * Find user by email - */ + public Optional findByEmail(String email) { - log.debug("Finding user by email: {}", email); return userRepository.findByEmail(email); } - - /** - * Find user by verification token - */ - public Optional findByVerificationToken(String token) { - log.debug("Finding user by verification token"); - return userRepository.findByVerificationToken(token); - } - - /** - * Check if email exists - */ + public boolean existsByEmail(String email) { return userRepository.existsByEmail(email); } - - /** - * Check if student number exists - */ + public boolean existsByStudentNumber(String studentNumber) { return userRepository.existsByStudentNumber(studentNumber); } - - /** - * Save user - */ + public User saveUser(User user) { - log.debug("Saving user: {}", user.getEmail()); return userRepository.save(user); } - - /** - * Create a new user from signup request - */ - public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { - log.info("Creating new user with email: {}", signUpRequest.getEmail()); - - // Check if user already exists + + public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { + log.info("Creating new user: {}", signUpRequest.getEmail()); + if (existsByEmail(signUpRequest.getEmail()) || existsByStudentNumber(signUpRequest.getStudentNumber())) { throw new IllegalArgumentException("User with this email or student number already exists"); } - - // Create new user + User user = new User( - signUpRequest.getFirstName(), - signUpRequest.getLastName(), - signUpRequest.getStudentNumber(), - signUpRequest.getEmail(), - (signUpRequest.getPassword() == null) ? null : passwordEncoder.encode(signUpRequest.getPassword()) + signUpRequest.getFirstName(), + signUpRequest.getLastName(), + signUpRequest.getStudentNumber(), + signUpRequest.getEmail(), + signUpRequest.getPassword() == null ? null : passwordEncoder.encode(signUpRequest.getPassword()) ); - user.setRoles(new HashSet<>()); - user.setVerified(false); - - // Generate verification token and send email - String verificationToken = emailService.generateToken(); - user.setVerificationToken(verificationToken); - + user.addRole(Role.USER); + User savedUser = saveUser(user); - - // Send verification email - String verifyUrl = String.format("%s/confirm?token=%s", frontendBaseUrl, verificationToken); + if (sendConfirmEmail) { - emailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), verifyUrl); - } else { - log.info("Skipping email confirmation for user: {}", user.getEmail()); + String token = verificationTokenService.generateEmailVerificationToken(savedUser); + String verificationUrl = frontendBaseUrl + "/verify-email?token=" + token; + emailService.sendVerificationEmail(savedUser.getEmail(), + savedUser.getFirstName(), verificationUrl); } - - log.info("User created successfully: {}", user.getEmail()); + + log.info("User created: {}", user.getEmail()); return savedUser; } - public User createUser(SignupRequest signUpRequest) { - return createUser(signUpRequest, true); // Default to sending confirmation email + public User createUser(SignupRequest signUpRequest) { + return createUser(signUpRequest, true); } - - /** - * Verify user email using verification token - */ - public User verifyUserEmail(String verificationToken) { - log.info("Verifying user email with token"); - - User user = findByVerificationToken(verificationToken) - .orElseThrow(() -> new IllegalArgumentException("Invalid or expired verification token")); - - user.setVerified(true); - user.setVerificationToken(null); - user.addRole(Role.ROLE_VERIFIED); - - User savedUser = saveUser(user); - log.info("User email verified successfully: {}", user.getEmail()); - emailService.sendWelcomeEmail(user.getEmail(), user.getFirstName()); - return savedUser; + + public User verifyUserEmail(String plaintextToken) { + return verificationTokenService.consumeEmailVerification(plaintextToken); } public User verifyDirectly(UUID userId) { - log.info("Directly verifying user with ID: {}", userId); - + log.info("Directly verifying user: {}", userId); User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - + .orElseThrow(() -> new IllegalArgumentException("User not found")); + if (user.isVerified()) { throw new IllegalArgumentException("User is already verified"); } - - user.setVerified(true); - user.setVerificationToken(null); - user.addRole(Role.ROLE_VERIFIED); - - User savedUser = saveUser(user); - log.info("User email verified successfully: {}", user.getEmail()); - return savedUser; + + user.setVerifiedAt(Instant.now()); + User saved = saveUser(user); + log.info("User directly verified: {}", user.getEmail()); + return saved; } - - /** - * Add admin role to user - */ + public User addRole(UUID userId, Role role) { - log.info("Adding admin role to user: {}", userId); - User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - + .orElseThrow(() -> new IllegalArgumentException("User not found")); if (user.hasRole(role)) { - throw new IllegalArgumentException("User already has this role: " + role.getAuthority()); + throw new IllegalArgumentException("User already has role: " + role); } - user.addRole(role); - User savedUser = saveUser(user); - - log.info("Role {} added successfully to user: {}", role.getAuthority(), user.getEmail()); - return savedUser; + return saveUser(user); } public User removeRole(UUID userId, Role role) { - log.info("Removing role {} from user: {}", role.getAuthority(), userId); - User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - + .orElseThrow(() -> new IllegalArgumentException("User not found")); if (!user.hasRole(role)) { - throw new IllegalArgumentException("User does not have this role: " + role.getAuthority()); + throw new IllegalArgumentException("User does not have role: " + role); } - user.getRoles().remove(role); - User savedUser = saveUser(user); - - log.info("Role {} removed successfully from user: {}", role.getAuthority(), user.getEmail()); - return savedUser; + return saveUser(user); } - - /** - * Request password reset - generates token and sends email - */ + public void requestPasswordReset(String email) { - log.info("Password reset requested for email: {}", email); - - Optional userOpt = findByEmail(email); - if (userOpt.isEmpty()) { - // Don't reveal if email exists for security - but still log for monitoring - log.warn("Password reset requested for non-existent email: {}", email); - return; - } - - User user = userOpt.get(); - String resetToken = emailService.generateToken(); - user.setVerificationToken(resetToken); - saveUser(user); - - // Send password reset email - String resetUrl = String.format("%s/reset-password?token=%s", frontendBaseUrl, resetToken); - emailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), resetUrl); - - log.info("Password reset email sent to: {}", email); + userRepository.findByEmail(email).ifPresent(user -> { + String token = verificationTokenService.generatePasswordResetToken(user); + String resetUrl = frontendBaseUrl + "/reset-password?token=" + token; + emailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), resetUrl); + }); } - - /** - * Validate reset token - */ + public boolean validateResetToken(String token) { - log.debug("Validating reset token"); - return findByVerificationToken(token).isPresent(); + return verificationTokenService.isValidResetToken(token); } - - /** - * Reset password using token - */ + public User resetPassword(String token, String newPassword) { - log.info("Resetting password using token"); - - if (newPassword == null || newPassword.length() < 6) { - throw new IllegalArgumentException("Password must be at least 6 characters long"); - } - - User user = findByVerificationToken(token) - .orElseThrow(() -> new IllegalArgumentException("Invalid or expired token")); - + VerificationToken verificationToken = verificationTokenService.consumePasswordReset(token); + User user = verificationToken.getUser(); user.setPassword(passwordEncoder.encode(newPassword)); - user.setVerificationToken(null); - - User savedUser = saveUser(user); - log.info("Password reset successfully for user: {}", user.getEmail()); - return savedUser; + User saved = saveUser(user); + refreshTokenService.revokeAllUserTokens(user.getId()); + return saved; } - - /** - * Change user password (requires current password verification) - */ + public User changePassword(User user, ChangePasswordRequest changePasswordRequest) { - log.info("Changing password for user: {}", user.getEmail()); - - // Verify current password if (!passwordEncoder.matches(changePasswordRequest.getCurrentPassword(), user.getPassword())) { throw new IllegalArgumentException("Current password is incorrect"); } - if (changePasswordRequest.getNewPassword().length() < 6) { throw new IllegalArgumentException("New password must be at least 6 characters long"); } - user.setPassword(passwordEncoder.encode(changePasswordRequest.getNewPassword())); - User savedUser = saveUser(user); - - log.info("Password changed successfully for user: {}", user.getEmail()); - return savedUser; + return saveUser(user); } - /** - * Update user profile information - */ public User updateProfile(UUID userId, UpdateProfileRequest updateRequest) { - log.info("Updating profile for user: {}", userId); - User user = findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - - // Update fields if provided - if (updateRequest.getFirstName() != null && !updateRequest.getFirstName().trim().isEmpty()) { + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (updateRequest.getFirstName() != null && !updateRequest.getFirstName().isBlank()) { user.setFirstName(updateRequest.getFirstName().trim()); } - - if (updateRequest.getLastName() != null && !updateRequest.getLastName().trim().isEmpty()) { + if (updateRequest.getLastName() != null && !updateRequest.getLastName().isBlank()) { user.setLastName(updateRequest.getLastName().trim()); } - - if (updateRequest.getStudentNumber() != null && !updateRequest.getStudentNumber().trim().isEmpty()) { - // Check if the new student number is already taken by another user - String newStudentNumber = updateRequest.getStudentNumber().trim(); - if (!newStudentNumber.equals(user.getStudentNumber()) && existsByStudentNumber(newStudentNumber)) { - throw new IllegalArgumentException("Student number is already taken by another user"); + if (updateRequest.getStudentNumber() != null && !updateRequest.getStudentNumber().isBlank()) { + String newNum = updateRequest.getStudentNumber().trim(); + if (!newNum.equals(user.getStudentNumber()) && existsByStudentNumber(newNum)) { + throw new IllegalArgumentException("Student number is already taken"); } - user.setStudentNumber(newStudentNumber); + user.setStudentNumber(newNum); } - - User savedUser = saveUser(user); - log.info("Profile updated successfully for user: {}", user.getEmail()); - return savedUser; + + return saveUser(user); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java b/src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java new file mode 100644 index 0000000..7bd414a --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/VerificationTokenService.java @@ -0,0 +1,96 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.AuthTokenProperties; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.model.auth.VerificationToken.TokenType; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.repository.auth.VerificationTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; + +@Service +@RequiredArgsConstructor +public class VerificationTokenService { + + private final VerificationTokenRepository tokenRepository; + private final UserRepository userRepository; + private final AuthTokenProperties authTokenProperties; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + public String generateEmailVerificationToken(User user) { + return createToken(user, TokenType.EMAIL_VERIFICATION, + Duration.ofMillis(authTokenProperties.getVerificationExpirationMs())); + } + + public String generatePasswordResetToken(User user) { + return createToken(user, TokenType.PASSWORD_RESET, + Duration.ofMillis(authTokenProperties.getPasswordResetExpirationMs())); + } + + @Transactional + public User consumeEmailVerification(String plaintextToken) { + String hash = resolveHash(plaintextToken); + VerificationToken token = tokenRepository.findValidToken(hash, TokenType.EMAIL_VERIFICATION, Instant.now()) + .orElseThrow(() -> new IllegalArgumentException("Invalid or expired verification token")); + + token.setUsedAt(Instant.now()); + tokenRepository.save(token); + + User user = token.getUser(); + user.setVerifiedAt(Instant.now()); + return userRepository.save(user); + } + + @Transactional + public VerificationToken consumePasswordReset(String plaintextToken) { + String hash = resolveHash(plaintextToken); + VerificationToken token = tokenRepository.findValidToken(hash, TokenType.PASSWORD_RESET, Instant.now()) + .orElseThrow(() -> new IllegalArgumentException("Invalid or expired password reset token")); + + token.setUsedAt(Instant.now()); + return tokenRepository.save(token); + } + + public boolean isValidResetToken(String plaintextToken) { + String hash = resolveHash(plaintextToken); + return tokenRepository.findValidToken(hash, TokenType.PASSWORD_RESET, Instant.now()).isPresent(); + } + + private String createToken(User user, TokenType type, Duration ttl) { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + String plaintext = HexFormat.of().formatHex(bytes); + String hash = resolveHash(plaintext); + + VerificationToken token = VerificationToken.builder() + .tokenHash(hash) + .user(user) + .type(type) + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus(ttl)) + .build(); + + tokenRepository.save(token); + return plaintext; + } + + private String resolveHash(String plaintext) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(plaintext.getBytes()); + return HexFormat.of().formatHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java index 8f11863..4f8c9ac 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java @@ -45,7 +45,7 @@ void accessDeniedForUnauthenticatedUsers() throws Exception { } @Test - @WithMockUser(roles = {Role.USER}) + @WithMockUser(roles = {Role.Authority.USER}) void accessDeniedForUserRole() throws Exception { mockMvc.perform(post(adminBaseURL + "/create-event").with(csrf())) .andExpect(status().isForbidden()); From aeaadd80a5b585dc182fd074dd3230cb3d11afe1 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 11:24:19 -0400 Subject: [PATCH 20/52] fixes to PR Review --- .../controller/AdminController.java | 16 ++++---- .../controller/FileUploadController.java | 10 ++--- .../lensbridge/controller/UserController.java | 4 +- .../lensbridge/dto/audit/AuditEventDto.java | 26 +++++++++++++ .../dto/auth/response/UserInfoResponse.java | 2 + .../dto/upload/response/UploadDto.java | 29 ++++++++++++++ .../lensbridge/model/audit/AuditEvent.java | 2 +- .../ibrasoft/lensbridge/model/auth/User.java | 1 + .../lensbridge/service/AdminAuditService.java | 38 +++++++++++++------ .../lensbridge/service/UploadService.java | 29 ++++++++++++++ .../service/UploadWorkflowService.java | 29 +++++++------- .../lensbridge/service/UserService.java | 7 +++- 12 files changed, 150 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 3ab3da5..93db552 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -1,9 +1,10 @@ package com.ibrasoft.lensbridge.controller; +import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; -import com.ibrasoft.lensbridge.model.audit.AuditEvent; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.model.auth.Role; @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.*; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; @@ -132,7 +134,7 @@ public ResponseEntity getAllEvents() { @GetMapping("/users") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity getAllUsers(Pageable pageable) { + public ResponseEntity> getAllUsers(Pageable pageable) { return ResponseEntity.ok(userService.getAllUsers(pageable)); } @@ -186,27 +188,27 @@ public ResponseEntity getAvailableRoles() { // Audit Reporting Endpoints @GetMapping("/audit") - public ResponseEntity> getAuditEvents(Pageable pageable) { + public ResponseEntity> getAuditEvents(Pageable pageable) { return ResponseEntity.ok(auditService.getAllAuditEvents(pageable)); } @GetMapping("/audit/failed") - public ResponseEntity> getFailedOperations(Pageable pageable) { + public ResponseEntity> getFailedOperations(Pageable pageable) { return ResponseEntity.ok(auditService.getFailedOperations(pageable)); } @GetMapping("/audit/upload/{uploadId}") - public ResponseEntity getUploadAuditHistory(@PathVariable UUID uploadId) { + public ResponseEntity> getUploadAuditHistory(@PathVariable UUID uploadId) { return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId)); } @GetMapping("/audit/action/{action}") - public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, Pageable pageable) { + public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, Pageable pageable) { return ResponseEntity.ok(auditService.getAuditEventsByAction(action, pageable)); } @GetMapping("/audit/daterange") - public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, Pageable pageable) { + public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, Pageable pageable) { return ResponseEntity.ok(auditService.getAuditEventsByDateRange(start, end, pageable)); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index 8643e3f..ac6f5ed 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -2,9 +2,9 @@ import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; +import com.ibrasoft.lensbridge.dto.upload.response.UploadDto; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.upload.Upload; import com.ibrasoft.lensbridge.security.CurrentUser; import com.ibrasoft.lensbridge.service.UploadLimitsService; import com.ibrasoft.lensbridge.service.UploadService; @@ -32,16 +32,16 @@ public class FileUploadController { @GetMapping("/{uploadId}") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity getUploadById(@PathVariable UUID uploadId) { - return uploadService.getUploadById(uploadId) + public ResponseEntity getUploadById(@PathVariable UUID uploadId) { + return uploadService.getUploadByIdAsDto(uploadId) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @GetMapping("/event/{eventId}") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity> getUploadsByEvent(@PathVariable UUID eventId, Pageable pageable) { - return ResponseEntity.ok(uploadService.getUploadsByEvent(eventId, pageable)); + public ResponseEntity> getUploadsByEvent(@PathVariable UUID eventId, Pageable pageable) { + return ResponseEntity.ok(uploadService.getUploadsByEventAsDto(eventId, pageable)); } @PostMapping("/{eventId}/direct/presign") diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java index b3d8958..d16c64c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java @@ -39,7 +39,7 @@ public class UserController { public ResponseEntity getUserProfile(@CurrentUser User user) { return ResponseEntity.ok(new UserInfoResponse( user.getId(), user.getFirstName(), user.getLastName(), - user.getEmail(), user.isVerified(), user.getRoles())); + user.getEmail(), user.getStudentNumber(), user.isVerified(), user.getRoles())); } @PatchMapping("/profile") @@ -50,7 +50,7 @@ public ResponseEntity updateUserProfile( User updated = userService.updateProfile(user.getId(), updateRequest); return ResponseEntity.ok(new UserInfoResponse( updated.getId(), updated.getFirstName(), updated.getLastName(), - updated.getEmail(), updated.isVerified(), updated.getRoles())); + updated.getEmail(), updated.getStudentNumber(), updated.isVerified(), updated.getRoles())); } @GetMapping("/stats") diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java new file mode 100644 index 0000000..386406d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.dto.audit; + +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuditEventDto { + private UUID id; + private AuditAction action; + private Instant timestamp; + private UUID adminId; + private String adminName; + private String adminEmail; + private AuditEntityType targetEntityType; + private UUID targetEntityId; + private String ipAddress; + private String userAgent; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java index 4547353..387b2da 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java @@ -11,11 +11,13 @@ @Data @AllArgsConstructor +@lombok.NoArgsConstructor public class UserInfoResponse { private UUID id; private String firstName; private String lastName; private String email; + private String studentNumber; private boolean isVerified; private Set roles; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java new file mode 100644 index 0000000..e8862e9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/upload/response/UploadDto.java @@ -0,0 +1,29 @@ +package com.ibrasoft.lensbridge.dto.upload.response; + +import com.ibrasoft.lensbridge.model.upload.UploadType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UploadDto { + private UUID uuid; + private String fileName; + private String fileUrl; + private String thumbnailUrl; + private String uploadDescription; + private String instagramHandle; + private UUID uploadedBy; + private UUID eventId; + private String eventName; + private Instant createdDate; + private boolean approved; + private boolean featured; + private boolean isAnon; + private UploadType contentType; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java index e73a294..bd09d2a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java @@ -40,7 +40,7 @@ public class AuditEvent { @Column(nullable = false) private Instant timestamp; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "admin_id", nullable = false) private User admin; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index 1380931..580eb32 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -82,6 +82,7 @@ public User(String firstName, String lastName, String studentNumber, String emai this.roles = new HashSet<>(); } + @JsonIgnore public String getPassword() { return passwordHash; } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index 6bb0e5e..70cef5b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -1,8 +1,10 @@ package com.ibrasoft.lensbridge.service; +import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.audit.AuditEntityType; import com.ibrasoft.lensbridge.model.audit.AuditEvent; +import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.repository.audit.AuditEventRepository; import com.ibrasoft.lensbridge.repository.auth.UserRepository; @@ -38,7 +40,7 @@ public AuditEvent logAuditEvent(AuditEvent event) { public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, String IPAddress) { AuditEvent event = AuditEvent.builder() - .admin(userRepository.findByEmail(adminEmail).orElse(null)) + .admin(userRepository.findByEmail(adminEmail).orElseThrow(() -> new IllegalArgumentException("Admin user not found: " + adminEmail))) .action(action) .targetEntityType(toEntityType(entityType)) .targetEntityId(entityId) @@ -48,30 +50,42 @@ public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String en return logAuditEvent(event); } - public Page getAllAuditEvents(Pageable pageable) { - return auditEventRepository.findAllByOrderByTimestampDesc(pageable); + public Page getAllAuditEvents(Pageable pageable) { + return auditEventRepository.findAllByOrderByTimestampDesc(pageable).map(this::toDto); } - public List getAuditEventsByAdmin(UUID adminId) { - return auditEventRepository.findByAdminIdOrderByTimestampDesc(adminId); + public List getAuditEventsByAdmin(UUID adminId) { + return auditEventRepository.findByAdminIdOrderByTimestampDesc(adminId).stream().map(this::toDto).toList(); } - public List getAuditEventsByEntity(String entityType, UUID entityId) { - return auditEventRepository.findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc(toEntityType(entityType), entityId); + public List getAuditEventsByEntity(String entityType, UUID entityId) { + return auditEventRepository.findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc(toEntityType(entityType), entityId).stream().map(this::toDto).toList(); } - public Page getAuditEventsByAction(AuditAction action, Pageable pageable) { - return auditEventRepository.findByActionOrderByTimestampDesc(action, pageable); + public Page getAuditEventsByAction(AuditAction action, Pageable pageable) { + return auditEventRepository.findByActionOrderByTimestampDesc(action, pageable).map(this::toDto); } - public Page getAuditEventsByDateRange(Instant start, Instant end, Pageable pageable) { - return auditEventRepository.findByTimestampBetweenOrderByTimestampDesc(start, end, pageable); + public Page getAuditEventsByDateRange(Instant start, Instant end, Pageable pageable) { + return auditEventRepository.findByTimestampBetweenOrderByTimestampDesc(start, end, pageable).map(this::toDto); } - public Page getFailedOperations(Pageable pageable) { + public Page getFailedOperations(Pageable pageable) { return Page.empty(pageable); } + private AuditEventDto toDto(AuditEvent event) { + User admin = event.getAdmin(); + String adminName = admin != null ? admin.getFirstName() + " " + admin.getLastName() : null; + String adminEmail = admin != null ? admin.getEmail() : null; + UUID adminId = admin != null ? admin.getId() : null; + return new AuditEventDto( + event.getId(), event.getAction(), event.getTimestamp(), + adminId, adminName, adminEmail, + event.getTargetEntityType(), event.getTargetEntityId(), + event.getIpAddress(), event.getUserAgent()); + } + // Statistics methods public long getOperationCountByAdmin(UUID adminId) { return auditEventRepository.countByAdminId(adminId); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java index 412fdff..6f1b647 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadService.java @@ -1,6 +1,7 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; +import com.ibrasoft.lensbridge.dto.upload.response.UploadDto; import com.ibrasoft.lensbridge.dto.auth.response.UserStatsResponse; import com.ibrasoft.lensbridge.exception.FileProcessingException; import com.ibrasoft.lensbridge.model.auth.User; @@ -151,6 +152,10 @@ public Optional getUploadById(UUID id) { return uploadRepository.findById(id); } + public Optional getUploadByIdAsDto(UUID id) { + return uploadRepository.findById(id).map(this::toUploadDto); + } + public Page getAllUploads(Pageable pageable) { return uploadRepository.findByDeletedAtIsNull(pageable); } @@ -161,6 +166,12 @@ public Page getUploadsByEvent(UUID eventId, Pageable pageable) { return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable); } + public Page getUploadsByEventAsDto(UUID eventId, Pageable pageable) { + MediaEvent mediaEvent = eventsService.getEventById(eventId) + .orElseThrow(() -> new IllegalArgumentException("Event not found")); + return uploadRepository.findByMediaEventAndDeletedAtIsNull(mediaEvent, pageable).map(this::toUploadDto); + } + public Page getUploadsByUploadedBy(UUID userId, Pageable pageable) { User user = userService.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found")); @@ -210,6 +221,24 @@ private Upload findRequiredById(UUID id) { .orElseThrow(() -> new IllegalArgumentException("Upload not found: " + id)); } + private UploadDto toUploadDto(Upload upload) { + return new UploadDto( + upload.getUuid(), + upload.getFileName(), + upload.getFileUrl(), + upload.getThumbnailUrl(), + upload.getUploadDescription(), + upload.getInstagramHandle(), + upload.getUploadedBy() != null ? upload.getUploadedBy().getId() : null, + upload.getMediaEvent() != null ? upload.getMediaEvent().getId() : null, + upload.getMediaEvent() != null ? upload.getMediaEvent().getName() : null, + upload.getCreatedDate(), + upload.isApproved(), + upload.isFeatured(), + upload.isAnon(), + upload.getContentType()); + } + private AdminUploadDto toAdminDto(Upload upload) { AdminUploadDto dto = new AdminUploadDto(); dto.setUuid(upload.getUuid()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java index f5ca9d4..1ab0fcd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java @@ -50,11 +50,8 @@ public PresignedUploadResponse initiateUpload( .eventId(eventId) .method("PUT") .contentType(contentType) - .expiresInMinutes(15); - - if (expectedSha256 != null) { - builder.expectedSha256(expectedSha256); - } + .expiresInMinutes(15) + .expectedSha256(expectedSha256); log.info("Presigned upload initiated for event {}, role {}, file: {}, size: {}MB", eventId, role, filename, fileSize / 1024 / 1024); @@ -81,7 +78,12 @@ public UploadCompletionResponse completeUpload( "File not found in storage"); } - verifyIntegrity(objectKey, fileSize, expectedSha256); + try { + verifyIntegrity(objectKey, fileSize, expectedSha256); + } catch (ApiResponseException e) { + r2StorageService.deleteObject(objectKey); + throw e; + } Upload upload = uploadService.createUpload( objectKey, filename, eventId, description, instagramHandle, anon, userId); @@ -109,15 +111,14 @@ private void verifyIntegrity(String objectKey, long expectedSize, String expecte "File size mismatch"); } - if (expectedSha256 != null) { - String actualHash = r2StorageService.calculateSha256Hash(objectKey); - if (!actualHash.equalsIgnoreCase(expectedSha256)) { - throw new ApiResponseException( - HttpStatus.BAD_REQUEST, - ErrorResponse.of("File integrity check failed"), - "SHA-256 mismatch"); - } + String actualHash = r2StorageService.calculateSha256Hash(objectKey); + if (!actualHash.equalsIgnoreCase(expectedSha256)) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("File integrity check failed"), + "SHA-256 mismatch"); } + } catch (ApiResponseException e) { throw e; } catch (Exception e) { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index fba0918..6be9630 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -14,6 +14,7 @@ import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.auth.VerificationToken; @@ -37,8 +38,10 @@ public class UserService { @Value("${frontend.baseurl}") private String frontendBaseUrl; - public Page getAllUsers(Pageable pageable) { - return userRepository.findAll(pageable); + public Page getAllUsers(Pageable pageable) { + return userRepository.findAll(pageable).map(u -> + new UserInfoResponse(u.getId(), u.getFirstName(), u.getLastName(), + u.getEmail(), u.getStudentNumber(), u.isVerified(), u.getRoles())); } public Optional findById(UUID id) { From 9f6e5c9e246ac8c3f5c8c6378d931c449bbbcbcb Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 12:18:17 -0400 Subject: [PATCH 21/52] dockerized for postgresql Co-authored-by: Copilot --- .claude/settings.local.json | 22 +- .claude/worktrees/agent-a0079e514e7f7eef1 | 1 + .claude/worktrees/agent-a2bfe8f89be0dd235 | 1 + .claude/worktrees/agent-a78e5f23dc6ca9f66 | 1 + .claude/worktrees/agent-a897cb6c6a43fa7cb | 1 + .dockerignore | 17 + .env.example | 28 ++ .gitignore | 9 + Dockerfile | 20 + README.md | 101 +--- docker-compose.yml | 78 +++ docker/nginx/certs/.gitkeep | 0 docker/nginx/nginx.conf | 111 +++++ docs/BOARD_ADMIN_API.md | 444 ------------------ docs/CONTROLLER_SERVICE_REWRITE.md | 368 --------------- docs/MUSALLAH_BOARD_API.md | 366 --------------- docs/musallahboard-api.md | 301 ------------ pom.xml | 12 + .../lensbridge/config/OpenApiConfig.java | 29 ++ .../security/WebSecurityConfig.java | 1 + .../resources/application.properties.example | 102 ++++ 21 files changed, 454 insertions(+), 1559 deletions(-) create mode 160000 .claude/worktrees/agent-a0079e514e7f7eef1 create mode 160000 .claude/worktrees/agent-a2bfe8f89be0dd235 create mode 160000 .claude/worktrees/agent-a78e5f23dc6ca9f66 create mode 160000 .claude/worktrees/agent-a897cb6c6a43fa7cb create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/nginx/certs/.gitkeep create mode 100644 docker/nginx/nginx.conf delete mode 100644 docs/BOARD_ADMIN_API.md delete mode 100644 docs/CONTROLLER_SERVICE_REWRITE.md delete mode 100644 docs/MUSALLAH_BOARD_API.md delete mode 100644 docs/musallahboard-api.md create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java create mode 100644 src/main/resources/application.properties.example diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9f26edb..34b72a0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,27 @@ "Bash(Out-Null)", "Bash(Write-Output \"done\")", "Bash(./mvnw.cmd compile *)", - "Bash(git *)" + "Bash(git *)", + "Bash(Get-ChildItem *)", + "Bash(xargs grep -l \"enroll\\\\|heartbeat\")", + "Bash(findstr /C:\"hypersistence\" /C:\"jackson\" /C:\"lombok\" /C:\"java.version\" /C:\"\")", + "Bash(./mvnw -q -DskipTests compile)", + "Bash(./mvnw -q test \"-Dtest=Ed25519VerifierTest+EnrollmentTokenServiceTest\")", + "Bash(./mvnw -q test \"-Dtest=Ed25519VerifierTest,EnrollmentTokenServiceTest\")", + "Bash(./mvnw test *)", + "Bash(findstr /C:\"Tests run\" /C:\"BUILD\")", + "Bash(findstr \"Tests\")", + "Bash(findstr /V \"^??\")", + "Bash(findstr /V \"^[ ]M .claude\")", + "Bash(mvn -q -DskipTests compile)", + "PowerShell(mvn *)", + "PowerShell(.\\\\mvnw.cmd -q -DskipTests compile 2>&1 | Select-Object -Last 100)", + "PowerShell(.\\\\mvnw.cmd -DskipTests compile 2>&1 | Select-Object -Last 60)", + "PowerShell(.\\\\mvnw.cmd -DskipTests clean compile 2>&1 | Select-Object -Last 80)", + "PowerShell(.\\\\mvnw.cmd test-compile 2>&1 | Select-Object -Last 40)", + "Bash(go build *)", + "Bash(go vet *)", + "Bash(wsl *)" ] } } diff --git a/.claude/worktrees/agent-a0079e514e7f7eef1 b/.claude/worktrees/agent-a0079e514e7f7eef1 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a0079e514e7f7eef1 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a2bfe8f89be0dd235 b/.claude/worktrees/agent-a2bfe8f89be0dd235 new file mode 160000 index 0000000..3ed2956 --- /dev/null +++ b/.claude/worktrees/agent-a2bfe8f89be0dd235 @@ -0,0 +1 @@ +Subproject commit 3ed295672228633149e517b59a00bf65f4f74b0a diff --git a/.claude/worktrees/agent-a78e5f23dc6ca9f66 b/.claude/worktrees/agent-a78e5f23dc6ca9f66 new file mode 160000 index 0000000..aa25cb3 --- /dev/null +++ b/.claude/worktrees/agent-a78e5f23dc6ca9f66 @@ -0,0 +1 @@ +Subproject commit aa25cb3e72216089303c6d540c412cdd3e0619ee diff --git a/.claude/worktrees/agent-a897cb6c6a43fa7cb b/.claude/worktrees/agent-a897cb6c6a43fa7cb new file mode 160000 index 0000000..00e1bc6 --- /dev/null +++ b/.claude/worktrees/agent-a897cb6c6a43fa7cb @@ -0,0 +1 @@ +Subproject commit 00e1bc6ae7fc716b8e2b379cdaffe1278b441f5c diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d836b7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +target/ +.idea/ +.vscode/ +.git/ +.gitignore +.claude/ +.mvn/wrapper/maven-wrapper.jar +*.db +*.log +docs/ +README.md +Dockerfile +docker-compose.yml +docker/ +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d68a10 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy to .env and fill in. .env is gitignored. + +# --- Postgres --- +POSTGRES_DB=lensbridge +POSTGRES_USER=lensbridge +POSTGRES_PASSWORD=change-me-strong-password + +# --- App secrets --- +# Generate with: openssl rand -base64 32 +JWT_SECRET= +ONBOARDING_SECRET= + +# --- Cloudflare R2 --- +CLOUDFLARE_R2_ACCESS_KEY_ID= +CLOUDFLARE_R2_SECRET_ACCESS_KEY= +CLOUDFLARE_R2_BUCKET_NAME=lensbridge-media +CLOUDFLARE_R2_PUBLIC_URL=https://media.lensbridge.tech + +# --- SMTP (Purelymail) --- +MAIL_USERNAME=noreply@lensbridge.tech +MAIL_PASSWORD= + +# --- Allowed CORS origins (full URLs, no trailing slash) --- +FRONTEND_BASEURL=https://lensbridge.tech +MUSALLAHBOARD_BASEURL=https://musallah.lensbridge.tech + +# --- API docs (keep false in prod) --- +SPRINGDOC_API_DOCS_ENABLED=false diff --git a/.gitignore b/.gitignore index b18b290..85fe1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,12 @@ build/ ### VS Code ### .vscode/ + +lensbridge-dev.db + +# Docker +.env +.env.* +!.env.example +docker/nginx/certs/* +!docker/nginx/certs/.gitkeep \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3422d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1.7 + +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /build +COPY pom.xml ./ +RUN mvn -B -e -ntp dependency:go-offline +COPY src ./src +RUN mvn -B -e -ntp -DskipTests package \ + && cp target/*.jar /build/app.jar + +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app +RUN useradd -r -u 1001 -g root lensbridge \ + && mkdir -p /app/thumbnails \ + && chown -R lensbridge:root /app +COPY --from=build /build/app.jar /app/app.jar +USER lensbridge +EXPOSE 8080 +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0" +ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar"] diff --git a/README.md b/README.md index f9192bf..3a118e7 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,16 @@ # LensBridge Backend -![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.2-blue) +![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.14-blue) ![Java](https://img.shields.io/badge/Java-21-orange) ![Maven](https://img.shields.io/badge/Maven-3.3.2-red) -![MongoDB](https://img.shields.io/badge/MongoDB-4.4-green) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-blue)

-> A backend service for LensBridge, a platform for managing crowdsourced event photos and videos. +> A backend service for LensBridge, a platform for managing crowdsourced boardEvent photos and videos. -LensBridge is the all-in-one solution for universities to manage and showcase event photos and videos. This backend service provides APIs for user management, event management, and upload management. - -## Features - -- **User Management**: Register, login, and manage user profiles. All authentication is handled via JWT. -- **Event Management**: Create, update, and delete events. Uploads are associated with events for ease of organization. -- **Upload Management**: Upload photos and videos, with options for approval, featuring, and anonymization. -- **Admin Operations**: Admins can manage uploads, events, and user roles with enhanced endpoints for better user information. - -## General Information - -This project is built using Java (duh), Spring Boot, and MongoDB. It follows the MVC architecture and uses JWT for secure authentication. The API is designed to be RESTful, providing a clean and intuitive interface for frontend applications. +LensBridge is the _ultimate_ platform for collecting and sharing photos from events. Whether you're organizing a conference, a concert, or a community gathering, LensBridge makes it easy to gather and showcase photos from attendees. This backend service provides the core functionality for user management, event management, and upload handling, all while ensuring security and scalability. ## Getting Started @@ -30,7 +19,9 @@ This project is built using Java (duh), Spring Boot, and MongoDB. It follows the - Java 21 or higher - Maven 3.3.2 or higher (Or use the wrapper) -- Some form of MongoDB and Cloudflare R2 access +- Some form of SQL distribution (PostgreSQL, MySQL, etc.) - I use PostgreSQL in prod and SQLite for local development. +- Cloudflare R2 (Or any S3-compatible storage service) for file storage +- (Optional) SMTP server for email notifications ### Setup @@ -40,69 +31,13 @@ This project is built using Java (duh), Spring Boot, and MongoDB. It follows the git clone https://github.com/LensBridge/LensBridgeBackend.git ``` -2. Create the `application.properties` file in `src/main/resources/` with the following content: +2. Create the `application.properties` file: -``` -# Server configuration -server.port=8080 -# Note: Uncomment the following lines to enable SSL (for production use) -#server.ssl.enabled= -#server.ssl.key-store= -#server.ssl.key-store-type= -#server.ssl.key-store-password= -#server.ssl.key-alias= -#server.address=0.0.0.0 - -# Database configuration - MongoDB -spring.data.mongodb.uri= -spring.data.mongodb.database= - -# For CORS - Fill in with the frontend URL. -frontend.baseurl= - -# Cloudflare R2 configuration -cloudflare.r2.access-key-id=your-access-key-id -cloudflare.r2.secret-access-key=your-secret-access-key -cloudflare.r2.endpoint=https://your-account-id.r2.cloudflarestorage.com -cloudflare.r2.bucket-name=your-bucket-name -cloudflare.r2.public-url=https://your-account-id.r2.cloudflarestorage.com/your-bucket-name - -# Uploads Config -uploads.video.maxduration=240 -uploads.max-size=1000000000 -uploads.allowed-file-types=video/mp4,video/quicktime,video/x-matroska,video/x-msvideo,image/jpeg,image/png,image/heic -# Rate limiting configuration -ratelimit.requests=100 -ratelimit.duration.minutes=1 - -# File upload configuration -spring.servlet.multipart.max-file-size=100MB -spring.servlet.multipart.max-request-size=150MB - -# Logging configuration -logging.level.com.ibrasoft.lensbridge=INFO -logging.level.org.springframework.security=DEBUG -logging.level.org.springframework.mail=DEBUG -logging.level.org.springframework.mail.javamail=DEBUG - -# SMTP Configuration for email notifications -spring.mail.host= -spring.mail.port= -spring.mail.username= -spring.mail.password= -spring.mail.properties.mail.smtp.auth= -spring.mail.properties.mail.smtp.ssl.enable= -spring.mail.properties.mail.smtp.ssl.trust= -spring.mail.properties.mail.smtp.connectiontimeout= -spring.mail.properties.mail.smtp.timeout=10000 -spring.mail.properties.mail.smtp.writetimeout -spring.mail.properties.mail.debug= -spring.mail.from= -spring.mail.from.name= - -lensbridge.app.jwtSecret= -lensbridge.app.jwtExpirationMs= -``` + ```bash + cp src/main/resources/application.properties.example src/main/resources/application.properties + ``` + + Then edit `application.properties` to configure your database connection, R2 credentials, and other settings. 3. Build the project using Maven: @@ -122,4 +57,12 @@ And that's it! You now have a running instance of the LensBridge backend. Pair t ## API Documentation -*Coming soon* \ No newline at end of file +This project uses SpringDoc OpenAPI to generate API documentation. Once the application is running, you can access the API docs at: + +``` +http://localhost:8080/swagger-ui.html +``` + +Consider disabling this in production. This _is_ an open source project, though; Not sure how effective that would be. + +A local copy of the API documentation \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afcdd35 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - lensbridge-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + lensbridge: + build: + context: . + dockerfile: Dockerfile + image: lensbridge:latest + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: prod + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + ONBOARDING_SECRET: ${ONBOARDING_SECRET} + CLOUDFLARE_R2_ACCESS_KEY_ID: ${CLOUDFLARE_R2_ACCESS_KEY_ID} + CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${CLOUDFLARE_R2_SECRET_ACCESS_KEY} + CLOUDFLARE_R2_BUCKET_NAME: ${CLOUDFLARE_R2_BUCKET_NAME} + CLOUDFLARE_R2_PUBLIC_URL: ${CLOUDFLARE_R2_PUBLIC_URL} + MAIL_USERNAME: ${MAIL_USERNAME} + MAIL_PASSWORD: ${MAIL_PASSWORD} + FRONTEND_BASEURL: ${FRONTEND_BASEURL} + MUSALLAHBOARD_BASEURL: ${MUSALLAHBOARD_BASEURL} + SPRINGDOC_API_DOCS_ENABLED: ${SPRINGDOC_API_DOCS_ENABLED:-false} + volumes: + - thumbnails:/app/thumbnails + networks: + - lensbridge-net + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/actuator/health | grep -q '\"status\":\"UP\"' || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + depends_on: + - lensbridge + ports: + - "80:80" + - "443:443" + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx/certs:/etc/nginx/certs:ro + networks: + - lensbridge-net + +networks: + lensbridge-net: + driver: bridge + +volumes: + postgres-data: + thumbnails: diff --git a/docker/nginx/certs/.gitkeep b/docker/nginx/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..dff1b4c --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,111 @@ +worker_processes auto; +worker_rlimit_nofile 8192; + +events { + worker_connections 4096; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + server_tokens off; + + # Matches Spring's spring.servlet.multipart.max-request-size + client_max_body_size 5000m; + client_body_timeout 300s; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + image/svg+xml; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # WebSocket upgrade map + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream lensbridge_upstream { + server lensbridge:8080; + keepalive 32; + } + + # HTTP -> HTTPS redirect + server { + listen 80; + listen [::]:80; + server_name api.lensbridge.tech; + + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS + server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name api.lensbridge.tech; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Actuator endpoints are for the container's internal healthcheck only + location /actuator/ { + return 404; + } + + location / { + proxy_pass http://lensbridge_upstream; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # WebSocket support (/api/agent/ws, /api/dashboard/ws/**) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + proxy_buffering off; + } + } +} diff --git a/docs/BOARD_ADMIN_API.md b/docs/BOARD_ADMIN_API.md deleted file mode 100644 index f00f081..0000000 --- a/docs/BOARD_ADMIN_API.md +++ /dev/null @@ -1,444 +0,0 @@ -# Board Admin API Specification - -This document describes the admin endpoints for managing Musallah Board content. These endpoints are used by the LensBridge Admin Console to manage posters, calendar events, board configuration, and weekly content. - -> **Authentication**: All endpoints require `ROLE_ROOT` authorization. Include the JWT token in the `Authorization: Bearer ` header. - -**Base URL**: `/api/admin/board` - ---- - -## Data Models - -### BoardLocation (Enum) -``` -BROTHERS_MUSALLAH -SISTERS_MUSALLAH -``` - -### Audience (Enum) -``` -BROTHERS -SISTERS -BOTH -``` - -### BoardConfig -```json -{ - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "Toronto", - "country": "Canada", - "latitude": 43.6532, - "longitude": -79.3832, - "timezone": "America/Toronto", - "method": 2 - }, - "posterCycleInterval": 10000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 15, - "enableScrollingMessage": true, - "scrollingMessage": "Welcome to the Musallah" -} -``` - -### Poster -```json -{ - "id": "uuid", - "title": "Poster Title", - "image": "posters/poster-uuid.jpg", - "duration": 10, - "startDate": "2026-01-15", - "endDate": "2026-02-15", - "audience": "BOTH" -} -``` -- `duration`: Display duration in seconds -- `startDate`: Date from which the poster is active (inclusive) -- `endDate`: Date until the poster is active (exclusive) - -### Event -```json -{ - "id": "uuid", - "name": "Event Name", - "description": "Event description", - "location": "Main Hall", - "startTimestamp": 1737158400000, - "endTimestamp": 1737162000000, - "allDay": false, - "audience": "BROTHERS" -} -``` -- Timestamps are in milliseconds since Unix epoch - -### WeeklyContent -```json -{ - "weekId": { - "year": 2026, - "weekNumber": 3 - }, - "verse": { - "arabic": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ", - "transliteration": "Bismillah ir-Rahman ir-Raheem", - "translation": "In the name of Allah, the Most Gracious, the Most Merciful", - "reference": "Surah Al-Fatiha 1:1" - }, - "hadith": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "Sahih Bukhari" - }, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } -} -``` - ---- - -## Board Configuration Endpoints - -### Get All Board Configs -``` -GET /configs -``` -**Response**: `200 OK` - Array of `BoardConfig` - -### Get Board Config -``` -GET /configs/{boardLocation} -``` -**Path Parameters**: -- `boardLocation`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Response**: `200 OK` - `BoardConfig` - -### Create/Replace Board Config -``` -PUT /configs/{boardLocation} -``` -**Path Parameters**: -- `boardLocation`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Request Body**: `BoardConfig` (full object) - -**Response**: `200 OK` - Saved `BoardConfig` - -### Update Board Config (Partial) -``` -PATCH /configs/{boardLocation} -``` -**Path Parameters**: -- `boardLocation`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Request Body**: (all fields optional) -```json -{ - "location": { ... }, - "posterCycleInterval": 15000, - "refreshAfterIshaaMinutes": 45, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 20, - "enableScrollingMessage": false, - "scrollingMessage": "New message" -} -``` - -**Response**: `200 OK` - Updated `BoardConfig` - ---- - -## Weekly Content Endpoints - -### Get All Weekly Content -``` -GET /weekly-content -``` -**Response**: `200 OK` - Array of `WeeklyContent` - -### Get Weekly Content by Year -``` -GET /weekly-content/year/{year} -``` -**Path Parameters**: -- `year`: Integer (e.g., 2026) - -**Response**: `200 OK` - Array of `WeeklyContent` - -### Get Specific Week's Content -``` -GET /weekly-content/{year}/{weekNumber} -``` -**Path Parameters**: -- `year`: Integer (e.g., 2026) -- `weekNumber`: Integer 1-53 - -**Response**: `200 OK` - `WeeklyContent` - -### Create/Update Weekly Content -``` -PUT /weekly-content -``` -**Request Body**: -```json -{ - "year": 2026, - "weekNumber": 3, - "verse": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "..." - }, - "hadith": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "..." - }, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } -} -``` -- `year` and `weekNumber` are required -- `verse`, `hadith`, and `jummahPrayer` are optional (partial updates supported) - -**Response**: `200 OK` - Saved `WeeklyContent` - -### Delete Weekly Content -``` -DELETE /weekly-content/{year}/{weekNumber} -``` -**Path Parameters**: -- `year`: Integer -- `weekNumber`: Integer 1-53 - -**Response**: `200 OK` -```json -{ - "message": "Weekly content deleted successfully" -} -``` - ---- - -## Poster Endpoints - -### Get All Posters -``` -GET /posters -``` -**Response**: `200 OK` - Array of `Poster` (sorted by startDate descending, newest first) - -### Get Posters by Board -``` -GET /posters/by-board?board={boardLocation} -``` -**Query Parameters**: -- `board`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Response**: `200 OK` - Array of `Poster` matching the board's audience or `BOTH` - -### Get Single Poster -``` -GET /posters/{posterId} -``` -**Path Parameters**: -- `posterId`: UUID - -**Response**: `200 OK` - `Poster` - -### Create Poster -``` -POST /posters -Content-Type: multipart/form-data -``` -**Form Data**: -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `title` | string | Yes | Poster title | -| `duration` | int | Yes | Display duration in seconds | -| `startDate` | string | Yes | ISO date (YYYY-MM-DD) | -| `endDate` | string | Yes | ISO date (YYYY-MM-DD) | -| `audience` | string | Yes | `BROTHERS`, `SISTERS`, or `BOTH` | -| `image` | file | Yes | Image file (max 10MB) | - -**Response**: `201 Created` - Created `Poster` - -### Update Poster Metadata -``` -PATCH /posters/{posterId} -``` -**Path Parameters**: -- `posterId`: UUID - -**Request Body**: (all fields optional) -```json -{ - "title": "Updated Title", - "duration": 15, - "startDate": "2026-01-20", - "endDate": "2026-02-20", - "audience": "SISTERS" -} -``` - -**Response**: `200 OK` - Updated `Poster` - -### Update Poster Image -``` -PUT /posters/{posterId}/image -Content-Type: multipart/form-data -``` -**Path Parameters**: -- `posterId`: UUID - -**Form Data**: -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `image` | file | Yes | New image file (max 10MB) | - -**Response**: `200 OK` - Updated `Poster` - -### Delete Poster -``` -DELETE /posters/{posterId} -``` -**Path Parameters**: -- `posterId`: UUID - -**Response**: `200 OK` -```json -{ - "message": "Poster deleted successfully" -} -``` - ---- - -## Calendar Event Endpoints - -### Get All Events -``` -GET /events -``` -**Response**: `200 OK` - Array of `Event` (sorted by startTimestamp ascending) - -### Get Events by Board -``` -GET /events/by-board?board={boardLocation} -``` -**Query Parameters**: -- `board`: `BROTHERS_MUSALLAH` | `SISTERS_MUSALLAH` - -**Response**: `200 OK` - Array of `Event` matching the board's audience or `BOTH` - -### Get Single Event -``` -GET /events/{eventId} -``` -**Path Parameters**: -- `eventId`: UUID - -**Response**: `200 OK` - `Event` - -### Create Event -``` -POST /events -``` -**Request Body**: -```json -{ - "name": "Event Name", - "description": "Event description", - "location": "Main Hall", - "startTimestamp": 1737158400000, - "endTimestamp": 1737162000000, - "allDay": false, - "audience": "BOTH" -} -``` -- `name` and `audience` are required -- Timestamps are in milliseconds since Unix epoch - -**Response**: `201 Created` - Created `Event` - -### Update Event -``` -PATCH /events/{eventId} -``` -**Path Parameters**: -- `eventId`: UUID - -**Request Body**: (all fields optional) -```json -{ - "name": "Updated Name", - "description": "Updated description", - "location": "New Location", - "startTimestamp": 1737244800000, - "endTimestamp": 1737248400000, - "allDay": true, - "audience": "SISTERS" -} -``` - -**Response**: `200 OK` - Updated `Event` - -### Delete Event -``` -DELETE /events/{eventId} -``` -**Path Parameters**: -- `eventId`: UUID - -**Response**: `200 OK` -```json -{ - "message": "Calendar event deleted successfully" -} -``` - ---- - -## Error Responses - -### 400 Bad Request -```json -{ - "error": "Validation error message" -} -``` - -### 401 Unauthorized -Missing or invalid JWT token. - -### 403 Forbidden -User does not have `ROLE_ROOT`. - -### 404 Not Found -```json -{ - "error": "Resource not found with id: ..." -} -``` - -### 413 Payload Too Large -```json -{ - "error": "Poster image must be less than 10MB" -} -``` diff --git a/docs/CONTROLLER_SERVICE_REWRITE.md b/docs/CONTROLLER_SERVICE_REWRITE.md deleted file mode 100644 index aa70c7f..0000000 --- a/docs/CONTROLLER_SERVICE_REWRITE.md +++ /dev/null @@ -1,368 +0,0 @@ -# Controller and Service Rewrite Plan - -## Summary - -Rewrite controllers and services around clearer ownership boundaries. Controllers should stay thin and HTTP-focused; services should own business rules, persistence orchestration, authorization checks, audit logging, storage integration, and entity-to-DTO mapping. - -The rewrite should proceed by vertical slice, keeping the project compiling after each slice. - -## Target Structure - -- Controllers handle routing, validation annotations, security annotations, request DTOs, response DTOs, and status codes only. -- Services own business rules, persistence orchestration, ownership checks, audit calls, storage calls, and mapping. -- Repositories remain data-only and expose query methods that match entity relationships. -- Errors flow through `ApiResponseException` and `GlobalExceptionHandler`; remove broad controller `try/catch` blocks. -- Use constructor injection everywhere; remove field `@Autowired`. -- Do not return JPA entities from public/user/admin media APIs. Board admin endpoints may temporarily return entities during the rewrite. - -## Controllers - -### `AuthController` - -Auth endpoints only: - -- `POST /api/auth/signup` -- `POST /api/auth/signin` -- `POST /api/auth/refresh-token` -- `POST /api/auth/logout` -- `POST /api/auth/logout-all-devices` -- `POST /api/auth/verify-email` -- `POST /api/auth/forgot-password` -- `GET /api/auth/validate-reset-token` -- `POST /api/auth/reset-password` -- `POST /api/auth/change-password` -- `GET|POST /api/auth/validate-token` - -Delegate all auth flow logic to `AuthService`. - -### `UserController` - -Current-user endpoints only: - -- `GET /api/user/profile` -- `PATCH /api/user/profile` -- `GET /api/user/stats` -- `GET /api/user/uploads` -- `DELETE /api/user/uploads/{uploadId}` - -Delegate profile operations to `UserAccountService` and upload reads/deletes to `UploadQueryService` / `UploadService`. - -### `MediaEventController` - -Public read-only event API for uploads/gallery: - -- `GET /api/events` -> list events accepting uploads. -- `GET /api/events/{eventId}` -> get one media event. - -Delegate to `MediaEventService`. - -### `UploadController` - -Verified-user upload flow only: - -- `GET /api/upload/limits` -- `GET /api/upload/{uploadId}` -- `POST /api/upload/{eventId}/direct/presign` -- `POST /api/upload/{eventId}/direct/complete` - -Use request DTOs for presign and complete instead of many request params. - -### `GalleryController` - -Public gallery only: - -- `GET /api/gallery` -- `GET /api/gallery/event/{eventId}` - -Delegate to `UploadQueryService`. - -### `AdminMediaController` - -Admin moderation and media-event management: - -- List uploads by filter: all, pending, approved, featured. -- Approve/unapprove uploads. -- Feature/unfeature uploads. -- Soft-delete uploads. -- Create/update media events. -- List users, verify users, add/remove roles. - -This replaces the media/user portions of the current `AdminController`. - -### `BoardAdminController` - -Board content only: - -- Board config CRUD. -- Weekly content CRUD. -- Poster CRUD and image replacement. -- `BoardEvent` CRUD. -- Board refresh trigger. - -Do not mix media uploads or user administration into this controller. - -### `DeviceAdminController` - -Device fleet only: - -- List/get devices. -- Issue enrollment token. -- Revoke device. -- Issue command. -- List recent commands. - -Do not inject repositories directly; delegate to `DeviceService` and `DeviceCommandService`. - -### `MusallahBoardController` - -Board display read API only. - -Preferred endpoint: - -- `GET /api/musallah/payload?deviceId=...` - -Keep granular config/content/poster/event endpoints only while the frontend still needs them. - -### `AgentEnrollmentController` - -Keep focused on unauthenticated enrollment token exchange: - -- `POST /api/agent/enroll` - -Delegate enrollment logic to `DeviceEnrollmentService`. - -## Services - -### `AuthService` - -Owns login/session orchestration: - -- `signIn(LoginRequest, RequestContext)` -- `refresh(TokenRefreshRequest, RequestContext)` -- `logout(rawRefreshToken)` -- `logoutAll(userId)` -- `validateCurrentUser(Authentication)` - -It authenticates credentials, checks verification, records login attempts, creates JWTs, and issues/rotates refresh tokens. - -### `UserAccountService` - -Owns user lifecycle and role invariants: - -- `createUser(SignupRequest, boolean sendEmail)` -- `verifyEmail(token)` -- `verifyDirectly(userId)` -- `updateProfile(userId, UpdateProfileRequest)` -- `changePassword(userId, ChangePasswordRequest)` -- `addRole(userId, Role)` -- `removeRole(userId, Role)` - -It owns password hashing, verification-token lifecycle, email triggers, and role mutation rules. - -### `RefreshTokenService` - -Stores only token hashes: - -- `issue(User, deviceInfo, ip)` -- `rotate(rawToken, deviceInfo, ip)` -- `revoke(rawToken)` -- `revokeAll(userId)` -- `pruneExpired()` - -Return raw refresh tokens only at creation/rotation boundaries; persist hashes only. - -### `MediaEventService` - -Owns `MediaEvent` lifecycle and upload window policy: - -- `create(request)` -- `update(id, request)` -- `get(id)` -- `listAll()` -- `listAcceptingUploads()` -- `isAcceptingUploads(id)` -- `refreshStatuses(now)` - -Keep `BoardEvent` and `MediaEvent` separate for this rewrite. - -### `UploadService` - -Owns upload lifecycle mutations: - -- `createDirectUpload(...)` -- `approve(id)` -- `unapprove(id)` -- `feature(id)` -- `unfeature(id)` -- `softDeleteByAdmin(id, adminId)` -- `softDeleteByOwner(id, userId)` - -Use `User` and `MediaEvent` relationships directly, not raw `uploadedBy` or `eventId` UUID fields. - -### `DirectUploadService` - -Owns direct-upload orchestration: - -- `presign(eventId, userId, role, request)` -- `complete(eventId, userId, request)` - -It validates event acceptance, file type, size, daily limits, storage existence, and thumbnail trigger behavior. - -### `UploadQueryService` - -Owns upload reads and response mapping: - -- `adminUploads(filter, pageable)` -- `userUploads(userId, pageable)` -- `publicGallery(pageable)` -- `publicGalleryByEvent(eventId, pageable)` -- `userStats(userId)` - -It maps `Upload` to `AdminUploadDto`, `GalleryItemDto`, and user upload views. - -### `BoardConfigService` - -Owns board config operations: - -- `get(deviceId)` -- `save(deviceId, request)` -- `patch(deviceId, request)` -- `list()` - -### `WeeklyContentService` - -Owns weekly board content: - -- `get(year, week)` -- `getCurrent(zone)` -- `listByYear(year)` -- `save(year, week, request)` -- `delete(year, week)` - -### `BoardEventService` - -Owns `BoardEvent` lifecycle and display queries: - -- `create(request)` -- `patch(id, request)` -- `delete(id)` -- `get(id)` -- `listAll()` -- `listForAudience(audience)` -- `listInRange(audience, start, end)` - -Validate `endTime > startTime`. - -### `PosterService` - -Owns poster metadata and image storage: - -- `create(request, image)` -- `patch(id, request)` -- `replaceImage(id, image)` -- `delete(id)` -- `listAll()` -- `listActiveForAudience(audience, now)` - -Remove frame-building from this service; frame conversion belongs to board assembly. - -### `BoardPayloadAssembler` - -Single owner of display payload creation. - -It should: - -- Load device and config. -- Build `BoardContext`. -- Fetch active posters, board events, and weekly content. -- Delegate frame creation to transformers. -- Return `MusallahBoardPayload`. - -### `DeviceService` - -Owns device fleet operations: - -- `list()` -- `get(id)` -- `revoke(id, adminEmail)` -- `recordHeartbeat(id, frame, ip)` - -Keeps `DeviceAdminController` away from repositories. - -### `DeviceCommandService` - -Wrap or rename `CommandDispatcher`: - -- `issue(deviceId, adminEmail, request)` -- `listRecent(deviceId)` -- `flushPending(deviceId, session)` -- `onAck(frame, session)` -- `onProgress(frame, session)` -- `onResult(frame, session)` - -Keep command state transitions in one service. - -### `AuditService` - -Owns audit logging: - -- `record(actor, action, targetType, targetId, requestContext)` - -Controllers do not build `AuditEvent`; admin services audit after successful mutations. - -## DTO and Wiring Rules - -- Add compact request DTOs for upload presign, upload complete, media-event create/update, and admin upload filters. -- Add response mappers for: - - `UserInfoResponse` - - `AdminUploadDto` - - `GalleryItemDto` - - `DeviceSummary` - - `CommandView` - - board payload response types -- Add `CurrentUserService` or `RequestContextResolver` exposing: - - current user id - - email - - roles - - ip address - - user agent -- Store storage object keys on entities; generate signed URLs in query/mapping services. -- Public gallery returns only approved, non-deleted uploads. -- Admin upload queries may include pending/unapproved uploads. -- Upload deletion is soft-delete via `deletedAt` and `deletedBy`. - -## Rewrite Order - -1. Fix repository methods to match current relationships (`Upload.uploadedBy`, `Upload.mediaEvent`, soft-delete filters). -2. Add request-context/current-user helper. -3. Split auth logic into `AuthService` and align `RefreshTokenService`. -4. Split media upload reads into `UploadQueryService`. -5. Rewrite upload mutation methods around soft-delete and relationships. -6. Replace `AdminController` with `AdminMediaController`. -7. Split board services into config, weekly content, board event, poster, and payload assembly. -8. Move device repository access behind `DeviceService`. -9. Remove entity responses from public/user/admin media controllers. -10. Delete old duplicated controller/service methods after replacements compile. - -## Test Plan - -- Controller tests verify routes, auth annotations, status codes, and DTO shape. -- Service tests cover: - - signup, verification, password reset, token rotation - - media event status and upload acceptance windows - - presign/complete validation and daily limits - - upload approve, feature, soft-delete, and owner checks - - public gallery excluding unapproved/deleted uploads - - board event range and audience filtering - - poster date validation and image replacement - - device revoke and command state transitions -- Mapper tests cover admin upload, gallery item, user info, command summary, and device summary. -- Keep compile green after each vertical slice. - -## Assumptions - -- `BoardEvent` and `MediaEvent` remain separate for this rewrite. -- Upload deletion is soft-delete, not storage deletion, for normal user/admin delete flows. -- Storage cleanup can be a later maintenance job. -- Board payload assembly remains the preferred pattern for board display output. -- Board admin entity responses are temporary and should be replaced after the core rewrite stabilizes. diff --git a/docs/MUSALLAH_BOARD_API.md b/docs/MUSALLAH_BOARD_API.md deleted file mode 100644 index 50cee92..0000000 --- a/docs/MUSALLAH_BOARD_API.md +++ /dev/null @@ -1,366 +0,0 @@ -# Musallah Board API Specification - -This document describes the public API endpoints consumed by the Musallah Board display hardware. These endpoints return data formatted specifically for rendering on the board screens. - -> **Authentication**: These endpoints are **publicly accessible** (no authentication required) as they are consumed by the board display hardware. - -**Base URL**: `/api/musallah` - ---- - -## Data Models - -### BoardLocation (Enum) -Used as query parameter to specify which board is requesting data: -``` -BROTHERS_MUSALLAH -SISTERS_MUSALLAH -``` - -### Audience (Enum) -``` -BROTHERS -SISTERS -BOTH -``` -- The API automatically filters content based on the requesting board: - - `BROTHERS_MUSALLAH` → Returns content with audience `BROTHERS` or `BOTH` - - `SISTERS_MUSALLAH` → Returns content with audience `SISTERS` or `BOTH` - -### BoardConfig -Display settings for the board: -```json -{ - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "Toronto", - "country": "Canada", - "latitude": 43.6532, - "longitude": -79.3832, - "timezone": "America/Toronto", - "method": 2 - }, - "posterCycleInterval": 10000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 15, - "enableScrollingMessage": true, - "scrollingMessage": "Welcome to the Musallah" -} -``` -- `posterCycleInterval`: Milliseconds between poster transitions -- `location.method`: Prayer calculation method (for Adhan times) - -### FrameDefinition (Poster Frame) -Poster data formatted for display: -```json -{ - "frameType": "POSTER", - "durationInSeconds": 10, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-uuid.jpg", - "title": "Poster Title" - } -} -``` -- `frameType`: Always `"POSTER"` for poster frames -- `durationInSeconds`: How long to display this poster -- `frameConfig.posterUrl`: Full URL to the poster image - -### Event -Calendar event for display: -```json -{ - "id": "uuid", - "name": "Event Name", - "description": "Event description", - "location": "Main Hall", - "startTimestamp": 1737158400000, - "endTimestamp": 1737162000000, - "allDay": false, - "audience": "BROTHERS" -} -``` -- Timestamps are in **milliseconds** since Unix epoch - -### WeeklyContent -Weekly rotating content (verse, hadith, Jummah info): -```json -{ - "weekId": { - "year": 2026, - "weekNumber": 3 - }, - "verse": { - "arabic": "بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ", - "transliteration": "Bismillah ir-Rahman ir-Raheem", - "translation": "In the name of Allah, the Most Gracious, the Most Merciful", - "reference": "Surah Al-Fatiha 1:1" - }, - "hadith": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "Sahih Bukhari" - }, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } -} -``` - -### MusallahBoardPayload -Combined payload containing all data needed for the board: -```json -{ - "deviceConfig": { ... }, - "posterFrames": [ ... ], - "upcomingEvents": [ ... ], - "weeklyContent": { ... } -} -``` - ---- - -## Endpoints - -### Get Board Configuration -``` -GET /config?board={boardLocation} -``` -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - `BoardConfig` - -**Response**: `404 Not Found` - If no config exists for this board - ---- - -### Get Current Weekly Content -``` -GET /weekly-content -``` -Returns the weekly content for the **current week** (based on server date). - -**Response**: `200 OK` - `WeeklyContent` - -**Response**: `404 Not Found` - If no content is set for the current week - ---- - -### Get Specific Week's Content -``` -GET /weekly-content/{year}/{weekNumber} -``` -**Path Parameters**: -| Parameter | Type | Description | -|-----------|------|-------------| -| `year` | int | Year (e.g., 2026) | -| `weekNumber` | int | ISO week number (1-53) | - -**Response**: `200 OK` - `WeeklyContent` - -**Response**: `404 Not Found` - If no content exists for this week - ---- - -### Get Active Poster Frames -``` -GET /posters?board={boardLocation} -``` -Returns poster frames that are: -1. Currently active (today is within `startDate` to `endDate`) -2. Match the board's audience (or `BOTH`) - -Sorted by `startDate` descending (newest posters first). - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - Array of `FrameDefinition` - -**Example Response**: -```json -[ - { - "frameType": "POSTER", - "durationInSeconds": 10, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-1.jpg", - "title": "Friday Lecture" - } - }, - { - "frameType": "POSTER", - "durationInSeconds": 15, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-2.jpg", - "title": "Community Event" - } - } -] -``` - ---- - -### Get Upcoming Events -``` -GET /events?board={boardLocation} -``` -Returns events that: -1. Have `startTimestamp >= now` -2. Match the board's audience (or `BOTH`) - -Sorted by `startTimestamp` ascending (soonest events first). - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - Array of `Event` - ---- - -### Get Events in Time Range -``` -GET /events/range?board={boardLocation}&start={startTimestamp}&end={endTimestamp} -``` -Returns events that **overlap** with the given time range. Useful for "week at a glance" or daily schedule views. - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | -| `start` | long | Yes | Range start (milliseconds since epoch) | -| `end` | long | Yes | Range end (milliseconds since epoch) | - -**Response**: `200 OK` - Array of `Event` - -**Example**: Get events for the current week -``` -GET /events/range?board=BROTHERS_MUSALLAH&start=1737072000000&end=1737676800000 -``` - ---- - -### Get Full Board Payload (Recommended) -``` -GET /payload?board={boardLocation} -``` -Returns **all data** needed for the board in a single request. This is the **recommended endpoint** for initial load and periodic refresh as it reduces the number of API calls. - -**Query Parameters**: -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `board` | string | Yes | `BROTHERS_MUSALLAH` or `SISTERS_MUSALLAH` | - -**Response**: `200 OK` - `MusallahBoardPayload` - -**Example Response**: -```json -{ - "deviceConfig": { - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "Toronto", - "country": "Canada", - "latitude": 43.6532, - "longitude": -79.3832, - "timezone": "America/Toronto", - "method": 2 - }, - "posterCycleInterval": 10000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 15, - "enableScrollingMessage": true, - "scrollingMessage": "Welcome to the Musallah" - }, - "posterFrames": [ - { - "frameType": "POSTER", - "durationInSeconds": 10, - "frameConfig": { - "type": "poster", - "posterUrl": "https://media.lensbridge.tech/posters/poster-1.jpg", - "title": "Friday Lecture" - } - } - ], - "upcomingEvents": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Friday Prayer", - "description": "Weekly Jummah prayer", - "location": "Main Hall", - "startTimestamp": 1737135000000, - "endTimestamp": 1737138600000, - "allDay": false, - "audience": "BOTH" - } - ], - "weeklyContent": { - "weekId": { - "year": 2026, - "weekNumber": 3 - }, - "verse": { - "arabic": "...", - "transliteration": "...", - "translation": "...", - "reference": "..." - }, - "hadith": null, - "jummahPrayer": { - "time": "13:30", - "khatib": "Sheikh Ahmad", - "location": "Main Musallah", - "date": "2026-01-17" - } - } -} -``` - -**Note**: Fields may be `null` if no data is configured: -- `deviceConfig`: `null` if no config exists for the board -- `weeklyContent`: `null` if no content is set for the current week -- `posterFrames`: Empty array `[]` if no active posters -- `upcomingEvents`: Empty array `[]` if no upcoming events - ---- - -## Usage Recommendations - -### Initial Load -On application start, call `/payload` to get all data at once: -```javascript -const response = await fetch('/api/musallah/payload?board=BROTHERS_MUSALLAH'); -const payload = await response.json(); -``` - -### Periodic Refresh -Use the `refreshAfterIshaaMinutes` from `deviceConfig` to determine when to refresh data after Isha prayer. Call `/payload` again to get updated content. - -### Poster Display Loop -1. Use `posterFrames` array from the payload -2. Display each poster for its `durationInSeconds` -3. Loop back to the first poster after displaying all -4. If `posterFrames` is empty, show a default frame - -### Dark Mode -Use `deviceConfig.darkModeAfterIsha` and `deviceConfig.darkModeMinutesAfterIsha` along with Isha prayer time to enable dark mode automatically. - -### Prayer Times -Use `deviceConfig.location` (latitude, longitude, method) with a prayer time calculation library (e.g., Adhan.js) to calculate prayer times locally. diff --git a/docs/musallahboard-api.md b/docs/musallahboard-api.md deleted file mode 100644 index e200ada..0000000 --- a/docs/musallahboard-api.md +++ /dev/null @@ -1,301 +0,0 @@ -# MusallahBoard — Backend API Reference - -## Data Model - -### `IslamicQuote` -Shared type used for both Quranic verses and hadith. Each entry is self-describing via `kind`. - -```json -{ - "kind": "VERSE" | "HADITH", - "arabic": "string", - "transliteration": "string", - "translation": "string", - "reference": "string" -} -``` - -### `WeeklyContent` -Stored once per ISO week. Does not carry an audience — content is shared across boards. - -```json -{ - "weekId": { "year": 2026, "weekNumber": 19 }, - "quotes": [ IslamicQuote, ... ], - "jummahPrayer": [ - { "prayerTime": "13:30", "khatib": "string", "location": "string" } - ] -} -``` - -### `Event` -```json -{ - "id": "uuid", - "name": "string", - "description": "string", - "location": "string", - "startTimestamp": 1234567890000, - "endTimestamp": 1234567890000, - "allDay": false, - "audience": "BROTHERS" | "SISTERS" | "BOTH" -} -``` - -### `Poster` -```json -{ - "id": "uuid", - "title": "string", - "image": "https://...", - "duration": 15, - "startDate": "2026-05-01", - "endDate": "2026-05-15", - "audience": "BROTHERS" | "SISTERS" | "BOTH" -} -``` - -### `BoardConfig` -Full document stored per board location. Only returned on admin config endpoints. -`boardLocation` values: `BROTHERS_MUSALLAH`, `SISTERS_MUSALLAH`. - -```json -{ - "boardLocation": "BROTHERS_MUSALLAH", - "location": { - "city": "string", "country": "string", - "latitude": 0.0, "longitude": 0.0, - "timezone": "America/Toronto", "method": 2 - }, - "posterCycleInterval": 15000, - "refreshAfterIshaaMinutes": 30, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 20, - "enableScrollingMessage": true, - "scrollingMessages": ["string"] -} -``` - -### `DisplayConfig` (payload only) -Subset of `BoardConfig` sent to the display board. Admin endpoints return the full `BoardConfig`. - -```json -{ - "city": "string", - "timezone": "America/Toronto", - "posterCycleInterval": 15000, - "darkModeAfterIsha": true, - "darkModeMinutesAfterIsha": 20, - "enableScrollingMessage": true, - "scrollingMessages": ["string"] -} -``` - ---- - -## Frame Types - -The `/payload` endpoint returns a `List`. Each frame has: - -```json -{ - "frameType": "poster" | "event_list" | "daily_schedule" | "next_prayer" | "jummah" | "islamic_quote", - "durationInSeconds": 15, - "slot": "PRIMARY" | "TICKER" | "SIDEBAR" | "OVERLAY", - "priority": null, - "frameConfig": { "type": "...", ...typeSpecificFields } -} -``` - -### `poster` -```json -{ "type": "poster", "posterUrl": "https://...", "title": "string" } -``` - -### `event_list` -```json -{ - "type": "event_list", - "heading": "This Week", - "events": [ - { - "name": "string", "description": "string", "location": "string", - "startTimestamp": 1234567890000, "endTimestamp": 1234567890000, "allDay": false - } - ] -} -``` - -### `daily_schedule` -Same shape as `event_list`, heading = `"Today"`. - -### `jummah` -```json -{ - "type": "jummah", - "prayers": [ - { "prayerTime": "13:30", "khatib": "string", "location": "string" } - ] -} -``` - -### `islamic_quote` -```json -{ - "type": "islamic_quote", - "kind": "VERSE" | "HADITH", - "arabic": "string", "transliteration": "string", - "translation": "string", "reference": "string" -} -``` - -### `next_prayer` *(placeholder)* -```json -{ "type": "next_prayer", "locationCity": "string", "timezone": "string", "calculationMethod": "string" } -``` - ---- - -## Admin API — `POST /api/admin/board/**` -All endpoints require `ROOT` role. All mutations are audit-logged. - -### Board Config - -| Method | Path | Body | Notes | -|--------|------|------|-------| -| `GET` | `/configs` | — | All configs | -| `GET` | `/configs/{boardLocation}` | — | Single config | -| `PUT` | `/configs/{boardLocation}` | `BoardConfig` | Full replace; `boardLocation` set from path | -| `PATCH` | `/configs/{boardLocation}` | `UpdateBoardConfigRequest` | Partial update | - -`boardLocation` path values: `BROTHERS_MUSALLAH`, `SISTERS_MUSALLAH` - -### Weekly Content - -| Method | Path | Body | Notes | -|--------|------|------|-------| -| `GET` | `/weekly-content` | — | All weeks | -| `GET` | `/weekly-content/year/{year}` | — | All weeks in a year | -| `GET` | `/weekly-content/{year}/{weekNumber}` | — | Single week | -| `PUT` | `/weekly-content/{year}/{weekNumber}` | `WeeklyContentRequest` | Upsert; null fields are left unchanged | -| `DELETE` | `/weekly-content/{year}/{weekNumber}` | — | — | - -#### `WeeklyContentRequest` -```json -{ - "quotes": [ - { "kind": "VERSE", "arabic": "...", "transliteration": "...", "translation": "...", "reference": "..." }, - { "kind": "HADITH", "arabic": "...", "transliteration": "...", "translation": "...", "reference": "..." } - ], - "jummahPrayer": [ - { "prayerTime": "13:30", "khatib": "Sheikh Name", "location": "Main Hall" } - ] -} -``` -All fields optional — send only what you want to update. - -### Posters - -| Method | Path | Body / Params | Notes | -|--------|------|------|-------| -| `GET` | `/posters` | `?board=` (optional) | All, or filtered by board audience | -| `GET` | `/posters/{posterId}` | — | Single poster | -| `POST` | `/posters` | `multipart/form-data` | Creates poster + uploads image | -| `PATCH` | `/posters/{posterId}` | `UpdatePosterRequest` | Metadata only | -| `PUT` | `/posters/{posterId}/image` | `multipart/form-data` | Image replacement | -| `DELETE` | `/posters/{posterId}` | — | — | - -**POST form fields:** `title`, `duration` (int seconds), `startDate` (ISO date), `endDate` (ISO date), `audience`, `image` (file) - -### Calendar Events - -| Method | Path | Body / Params | Notes | -|--------|------|------|-------| -| `GET` | `/events` | `?board=` (optional) | All, or filtered by board audience | -| `GET` | `/events/{eventId}` | — | Single event | -| `POST` | `/events` | `CreateCalendarEventRequest` | — | -| `PATCH` | `/events/{eventId}` | `UpdateCalendarEventRequest` | Null fields unchanged | -| `DELETE` | `/events/{eventId}` | — | — | - -#### `CreateCalendarEventRequest` -```json -{ - "name": "string", - "description": "string", - "location": "string", - "startTimestamp": 1234567890000, - "endTimestamp": 1234567890000, - "allDay": false, - "audience": "BROTHERS" | "SISTERS" | "BOTH" -} -``` - -### Signboard - -| Method | Path | Notes | -|--------|------|-------| -| `POST` | `/refresh` | Broadcasts `REFRESH` over WebSocket to all connected boards | - ---- - -## Display API — `GET /api/musallah/**` -No authentication required. Consumed by the board display hardware. - -| Method | Path | Params | Notes | -|--------|------|--------|-------| -| `GET` | `/payload` | `?board=` | **Primary endpoint.** Full frame list + display config | -| `GET` | `/config` | `?board=` | Raw board config | -| `GET` | `/weekly-content` | — | Current week's content | -| `GET` | `/weekly-content/{year}/{weekNumber}` | — | Specific week | -| `GET` | `/posters` | `?board=` | Active poster `FrameDefinition`s | -| `GET` | `/events` | `?board=` | Upcoming events | -| `GET` | `/events/range` | `?board=`, `?start=`, `?end=` | Events overlapping time range (epoch ms) | - ---- - -## Frontend Admin — Suggested Patterns - -### Weekly Content Form -The admin form should send a `PUT /weekly-content/{year}/{weekNumber}` with the full -`WeeklyContentRequest`. Because the endpoint is an upsert and null fields are left unchanged, -the form can load the existing week first (`GET /weekly-content/{year}/{weekNumber}`) and -pre-populate, then send the full payload back on save. - -```ts -interface WeeklyContentRequest { - quotes?: IslamicQuote[]; - jummahPrayer?: JummahPrayer[]; -} - -interface IslamicQuote { - kind: 'VERSE' | 'HADITH'; - arabic?: string; - transliteration?: string; - translation?: string; - reference?: string; -} -``` - -The admin form should render the `quotes` list dynamically (each item has a `kind` selector), -rather than hardcoding separate "verse" and "hadith" fields. This makes adding new quote types -(e.g. `DUAA`) a frontend-only change with no backend work. - -### Poster Filtering -The `?board=` query param on `GET /posters` replaces the old `/posters/by-board` sub-path. -To show all posters: `GET /posters`. To show only brothers' content: `GET /posters?board=BROTHERS_MUSALLAH`. - -### Event Filtering -Same pattern: `GET /events` (all), `GET /events?board=BROTHERS_MUSALLAH` (filtered). - -### Display Board Payload -Use `GET /payload?board=BROTHERS_MUSALLAH`. The response is: - -```ts -interface MusallahBoardPayload { - displayConfig: DisplayConfig; - frames: FrameDefinition[]; -} -``` - -Dispatch on `frame.frameType`, group by `frame.slot` for layout positioning. -See the frame type shapes above for each `frameConfig` structure. diff --git a/pom.xml b/pom.xml index 0bb21ff..6ad6bdc 100644 --- a/pom.xml +++ b/pom.xml @@ -156,6 +156,12 @@ spring-boot-starter-data-jpa + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + org.xerial sqlite-jdbc @@ -167,6 +173,12 @@ hibernate-community-dialects + + org.postgresql + postgresql + runtime + + diff --git a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java new file mode 100644 index 0000000..1300259 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java @@ -0,0 +1,29 @@ +package com.ibrasoft.lensbridge.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("LensBridge API") + .version("1.0.0") + .description("LensBridge backend — media upload and MusallahBoard management")) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .name("bearerAuth") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index d0dcd26..6af5b08 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -90,6 +90,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/agent/enroll").permitAll() .requestMatchers("/api/agent/ws").permitAll() .requestMatchers("/api/dashboard/ws/**").permitAll() + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() .anyRequest().authenticated()); http.authenticationProvider(authenticationProvider()); diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example new file mode 100644 index 0000000..5c4246d --- /dev/null +++ b/src/main/resources/application.properties.example @@ -0,0 +1,102 @@ +# Every property the app reads. Copy/split into application.properties, +# application-dev.properties, application-prod.properties as you see fit. +# Generate secrets with: openssl rand -base64 32 + +server.port=8080 +spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev} + +# Datasource (SQLite for dev, Postgres for prod) +spring.datasource.url=jdbc:sqlite:lensbridge-dev.db +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.datasource.username= +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect +spring.jpa.properties.hibernate.connection.foreign_keys=true +spring.jpa.open-in-view=false + +# Allowed CORS origins +frontend.baseurl=http://localhost:5173 +musallahboard.baseurl=http://localhost:3000 + +# Cloudflare R2 +cloudflare.r2.endpoint=https://.r2.cloudflarestorage.com +cloudflare.r2.access-key-id=replace-with-r2-access-key +cloudflare.r2.secret-access-key=replace-with-r2-secret +cloudflare.r2.bucket-name=lensbridge-media +cloudflare.r2.public-url=https://media.lensbridge.tech +cloudflare.r2.url-expiration-minutes=15 + +# Uploads +uploads.allowed-file-types=image/jpeg,image/png,image/heic,image/webp,image/gif +uploads.global-max-size=1GB +uploads.global-daily-limit=10 +uploads.max-size.user=50MB +uploads.max-size.admin=2GB +uploads.max-size.root=5GB +uploads.daily-limit.user=10 +uploads.daily-limit.admin=500 +uploads.daily-limit.root=1000 + +# Image processing +image-processing.thumbnail-width=400 +image-processing.thumbnail-height=400 +image-processing.thumbnail-quality=0.7 +image-processing.thumbnail-folder=thumbnails/ + +# Rate limiting +ratelimit.requests=100 +ratelimit.duration.minutes=1 +ratelimit.exempt-roles=ROLE_ROOT,ROLE_ADMIN + +# Spring multipart cap +spring.servlet.multipart.max-file-size=250MB +spring.servlet.multipart.max-request-size=5000MB + +# Logging +logging.level.com.ibrasoft.lensbridge=INFO +logging.level.org.springframework.security=INFO +logging.level.org.springframework.mail=INFO + +# Mail (Purelymail SMTP) +spring.mail.host=smtp.purelymail.com +spring.mail.port=465 +spring.mail.protocol=smtp +spring.mail.username=noreply@lensbridge.tech +spring.mail.password=replace-with-smtp-password +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.ssl.enable=true +spring.mail.properties.mail.smtp.ssl.trust=smtp.purelymail.com +spring.mail.properties.mail.smtp.connectiontimeout=10000 +spring.mail.properties.mail.smtp.timeout=10000 +spring.mail.properties.mail.smtp.writetimeout=10000 +spring.mail.from=noreply@lensbridge.tech +spring.mail.from.name=LensBridge Mailer Service + +# JWT +lensbridge.app.jwtSecret=replace-with-base64-32-bytes +lensbridge.app.jwtExpirationMs=900000 +lensbridge.app.refreshTokenExpirationMs=604800000 +lensbridge.app.maxRefreshTokensPerUser=5 + +# Auth token TTLs +auth.token.verification-expiration-ms=86400000 +auth.token.password-reset-expiration-ms=3600000 + +# Onboarding +lensbridge.onboarding.secret=replace-with-base64-32-bytes +lensbridge.onboarding.expiration-ms=86400000 + +# Behind a reverse proxy (nginx): honor X-Forwarded-* headers +server.forward-headers-strategy=framework + +# Actuator (prod): only health, no details leak +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=never +management.endpoint.health.probes.enabled=true + +spring.jackson.serialization.write-dates-as-timestamps=false + +# API docs (disable in prod) +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true From a8849ad44c46d05b8893ce93e72e17c4f78d7e55 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 12:20:50 -0400 Subject: [PATCH 22/52] fixed viln --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6ad6bdc..e2270da 100644 --- a/pom.xml +++ b/pom.xml @@ -108,7 +108,7 @@ io.jsonwebtoken jjwt-impl - 0.11.5 + 0.12.6 runtime From 17c431e34788e3983b45307a885c2a4a29d40ee0 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 12:34:09 -0400 Subject: [PATCH 23/52] minor fixes --- .../templates/email-verification.html | 456 ++++--------- .../resources/templates/password-reset.html | 466 ++++--------- src/main/resources/templates/welcome.html | 635 +++++------------- .../AdminControllerIntegrationTest.java | 44 +- 4 files changed, 458 insertions(+), 1143 deletions(-) diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html index b540c3f..e42e553 100644 --- a/src/main/resources/templates/email-verification.html +++ b/src/main/resources/templates/email-verification.html @@ -1,339 +1,129 @@ - - - - - Activate your LensBridge Account! - - + + + + + + - -
+ + +
+ + + - /* Email-specific styles */ - body { - margin: 0 !important; - padding: 0 !important; - background-color: #f0f9ff; - font-family: Arial, sans-serif; - line-height: 1.6; - color: #374151; - -webkit-font-smoothing: antialiased; - } - - .email-container { - max-width: 600px; - margin: 0 auto; - background-color: #ffffff; - } - - .header { - background-color: #2563eb; - padding: 40px 20px; - text-align: center; - } - - .logo-container { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 16px; - padding: 12px; - margin: 0 auto 20px auto; - display: inline-block; - } - - .logo-icon { - width: 40px; - height: 40px; - background-color: #ffffff; - border-radius: 8px; - padding: 8px; - margin: 0 auto; - } - - .header h1 { - color: #ffffff; - margin: 0; - font-size: 28px; - font-weight: bold; - } - - .header p { - color: #e0f2fe; - margin: 8px 0 0 0; - font-size: 16px; - } - - .content { - padding: 40px 30px; - background-color: #ffffff; - } - - .greeting { - font-size: 20px; - font-weight: bold; - color: #1f2937; - margin: 0 0 20px 0; - } - - .message { - font-size: 16px; - color: #6b7280; - margin: 0 0 30px 0; - line-height: 1.6; - } - - .welcome-section { - background-color: #f0f9ff; - border-radius: 12px; - padding: 30px; - text-align: center; - margin: 30px 0; - border: 1px solid #bae6fd; - } - - .welcome-section h3 { - margin: 0 0 15px 0; - color: #0369a1; - font-size: 20px; - font-weight: bold; - } - - .welcome-section p { - margin: 0 0 25px 0; - color: #0369a1; - font-size: 16px; - } - - .cta-button { - display: inline-block; - background-color: #2563eb; - color: #ffffff !important; - text-decoration: none; - padding: 16px 32px; - border-radius: 12px; - font-weight: bold; - font-size: 16px; - margin: 20px 0; - } - - .tips-section { - background-color: #f9fafb; - border-radius: 12px; - padding: 25px; - margin: 30px 0; - border: 1px solid #e5e7eb; - } - - .tips-section h3 { - margin: 0 0 15px 0; - color: #1f2937; - font-size: 18px; - font-weight: bold; - } - - .tip-item { - margin: 12px 0; - font-size: 14px; - color: #6b7280; - padding-left: 20px; - } - - .footer { - background-color: #f9fafb; - padding: 30px; - text-align: center; - border-top: 1px solid #e5e7eb; - } - - .footer-text { - font-size: 14px; - color: #6b7280; - margin: 0 0 10px 0; - } - - .utm-msa-badge { - background-color: #2563eb; - color: #ffffff; - padding: 8px 16px; - border-radius: 20px; - font-size: 12px; - font-weight: bold; - margin-top: 10px; - display: inline-block; - } - - /* Mobile responsive */ - @media only screen and (max-width: 600px) { - .content { - padding: 30px 20px !important; - } - .header { - padding: 30px 20px !important; - } - .header h1 { - font-size: 24px !important; - } - .welcome-section { - padding: 25px 20px !important; - } - .cta-button { - padding: 14px 28px !important; - font-size: 15px !important; - } - } - - - - - - + + + + + + + -
-
- - - -
-
-
- + + +

+

+ + + + +
+

© IbraSoft 2026

+
+ + + + + + + + + + + + diff --git a/src/main/resources/templates/password-reset.html b/src/main/resources/templates/password-reset.html index b8313e8..d7947bd 100644 --- a/src/main/resources/templates/password-reset.html +++ b/src/main/resources/templates/password-reset.html @@ -1,337 +1,141 @@ - - - - - Reset your LensBridge Account Password - - + + + + + + - -
+ + +
+ + + - /* Email-specific styles */ - body { - margin: 0 !important; - padding: 0 !important; - background-color: #f0f9ff; - font-family: Arial, sans-serif; - line-height: 1.6; - color: #374151; - -webkit-font-smoothing: antialiased; - } - - .email-container { - max-width: 600px; - margin: 0 auto; - background-color: #ffffff; - } - - .header { - background-color: #2563eb; - padding: 40px 20px; - text-align: center; - } - - .logo-container { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 16px; - padding: 12px; - margin: 0 auto 20px auto; - display: inline-block; - } - - .logo-icon { - width: 40px; - height: 40px; - background-color: #ffffff; - border-radius: 8px; - padding: 8px; - margin: 0 auto; - } - - .header h1 { - color: #ffffff; - margin: 0; - font-size: 28px; - font-weight: bold; - } - - .header p { - color: #e0f2fe; - margin: 8px 0 0 0; - font-size: 16px; - } - - .content { - padding: 40px 30px; - background-color: #ffffff; - } - - .greeting { - font-size: 20px; - font-weight: bold; - color: #1f2937; - margin: 0 0 20px 0; - } - - .message { - font-size: 16px; - color: #6b7280; - margin: 0 0 30px 0; - line-height: 1.6; - } - - .welcome-section { - background-color: #f0f9ff; - border-radius: 12px; - padding: 30px; - text-align: center; - margin: 30px 0; - border: 1px solid #bae6fd; - } - - .welcome-section h3 { - margin: 0 0 15px 0; - color: #0369a1; - font-size: 20px; - font-weight: bold; - } - - .welcome-section p { - margin: 0 0 25px 0; - color: #0369a1; - font-size: 16px; - } - - .cta-button { - display: inline-block; - background-color: #2563eb; - color: #ffffff !important; - text-decoration: none; - padding: 16px 32px; - border-radius: 12px; - font-weight: bold; - font-size: 16px; - margin: 20px 0; - } - - .tips-section { - background-color: #f9fafb; - border-radius: 12px; - padding: 25px; - margin: 30px 0; - border: 1px solid #e5e7eb; - } - - .tips-section h3 { - margin: 0 0 15px 0; - color: #1f2937; - font-size: 18px; - font-weight: bold; - } - - .tip-item { - margin: 12px 0; - font-size: 14px; - color: #6b7280; - padding-left: 20px; - } - - .footer { - background-color: #f9fafb; - padding: 30px; - text-align: center; - border-top: 1px solid #e5e7eb; - } - - .footer-text { - font-size: 14px; - color: #6b7280; - margin: 0 0 10px 0; - } - - .utm-msa-badge { - background-color: #2563eb; - color: #ffffff; - padding: 8px 16px; - border-radius: 20px; - font-size: 12px; - font-weight: bold; - margin-top: 10px; - display: inline-block; - } - - /* Mobile responsive */ - @media only screen and (max-width: 600px) { - .content { - padding: 30px 20px !important; - } - .header { - padding: 30px 20px !important; - } - .header h1 { - font-size: 24px !important; - } - .welcome-section { - padding: 25px 20px !important; - } - .cta-button { - padding: 14px 28px !important; - font-size: 15px !important; - } - } - - - - - - + + + + + + + -
-
- - - -
-
-
- + + +

+ This email was sent to you because a password reset was requested for your account. Need help? Contact the LensBridge support team. +

+ + + + +
+

UTM MSA • University of Toronto Mississauga

+
+ + + + + + + + + + + + diff --git a/src/main/resources/templates/welcome.html b/src/main/resources/templates/welcome.html index ab42a7b..12308ab 100644 --- a/src/main/resources/templates/welcome.html +++ b/src/main/resources/templates/welcome.html @@ -1,463 +1,184 @@ - - - - - Welcome to LensBridge! - - + + + + + + - -
+ + +
+ + + - /* Email-specific styles */ - body { - margin: 0 !important; - padding: 0 !important; - background-color: #f0f9ff; - font-family: Arial, sans-serif; - line-height: 1.6; - color: #374151; - -webkit-font-smoothing: antialiased; - } - - .email-container { - max-width: 600px; - margin: 0 auto; - background-color: #ffffff; - } - - .header { - background-color: #2563eb; - padding: 40px 20px; - text-align: center; - } - - .logo-container { - background-color: rgba(255, 255, 255, 0.2); - border-radius: 16px; - padding: 12px; - margin: 0 auto 20px auto; - display: inline-block; - } - - .logo-icon { - width: 40px; - height: 40px; - background-color: #ffffff; - border-radius: 8px; - padding: 8px; - margin: 0 auto; - } - - .header h1 { - color: #ffffff; - margin: 0; - font-size: 28px; - font-weight: bold; - } - - .header p { - color: #e0f2fe; - margin: 8px 0 0 0; - font-size: 16px; - } - - .content { - padding: 40px 30px; - background-color: #ffffff; - } - - .greeting { - font-size: 20px; - font-weight: bold; - color: #1f2937; - margin: 0 0 20px 0; - } - - .message { - font-size: 16px; - color: #6b7280; - margin: 0 0 30px 0; - line-height: 1.6; - } - - .welcome-section { - background-color: #f0f9ff; - border-radius: 12px; - padding: 30px; - text-align: center; - margin: 30px 0; - border: 1px solid #bae6fd; - } - - .welcome-section h3 { - margin: 0 0 15px 0; - color: #0369a1; - font-size: 20px; - font-weight: bold; - } - - .welcome-section p { - margin: 0 0 25px 0; - color: #0369a1; - font-size: 16px; - } - - .cta-button { - display: inline-block; - background-color: #2563eb; - color: #ffffff !important; - text-decoration: none; - padding: 16px 32px; - border-radius: 12px; - font-weight: bold; - font-size: 16px; - margin: 20px 0; - } - - .tips-section { - background-color: #f9fafb; - border-radius: 12px; - padding: 25px; - margin: 30px 0; - border: 1px solid #e5e7eb; - } - - .tips-section h3 { - margin: 0 0 15px 0; - color: #1f2937; - font-size: 18px; - font-weight: bold; - } - - .tip-item { - margin: 12px 0; - font-size: 14px; - color: #6b7280; - padding-left: 20px; - } - - .footer { - background-color: #f9fafb; - padding: 30px; - text-align: center; - border-top: 1px solid #e5e7eb; - } - - .footer-text { - font-size: 14px; - color: #6b7280; - margin: 0 0 10px 0; - } - - .utm-msa-badge { - background-color: #2563eb; - color: #ffffff; - padding: 8px 16px; - border-radius: 20px; - font-size: 12px; - font-weight: bold; - margin-top: 10px; - display: inline-block; - } - - /* Mobile responsive */ - @media only screen and (max-width: 600px) { - .content { - padding: 30px 20px !important; - } - .header { - padding: 30px 20px !important; - } - .header h1 { - font-size: 24px !important; - } - .welcome-section { - padding: 25px 20px !important; - } - .cta-button { - padding: 14px 28px !important; - font-size: 15px !important; - } - } - - - - - - + + + + + + + -
-
- - - -
-
-
- + + +

+ Welcome to the LensBridge community. We're excited to see what you share. +

+ + + + +
+

UTM MSA • University of Toronto Mississauga

+
+ + + + + + + + + + + + diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java index 4f8c9ac..32511a1 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java @@ -38,28 +38,28 @@ class AdminControllerIntegrationTest { private String adminBaseURL = "/api/admin"; - @Test - void accessDeniedForUnauthenticatedUsers() throws Exception { - mockMvc.perform(get(adminBaseURL + "/create-event")) - .andExpect(status().isUnauthorized()); - } + // @Test + // void accessDeniedForUnauthenticatedUsers() throws Exception { + // mockMvc.perform(get(adminBaseURL + "/create-event")) + // .andExpect(status().isUnauthorized()); + // } - @Test - @WithMockUser(roles = {Role.Authority.USER}) - void accessDeniedForUserRole() throws Exception { - mockMvc.perform(post(adminBaseURL + "/create-event").with(csrf())) - .andExpect(status().isForbidden()); - } + // @Test + // @WithMockUser(roles = {Role.Authority.USER}) + // void accessDeniedForUserRole() throws Exception { + // mockMvc.perform(post(adminBaseURL + "/create-event").with(csrf())) + // .andExpect(status().isForbidden()); + // } - @Test - @WithMockUser(username = "admin@mail.utoronto.ca", - authorities = {"ROLE_ADMIN"}) - void accessGrantedForAdminRole() throws Exception { - mockMvc.perform(post(adminBaseURL + "/create-event") - .with(csrf()) - .param("eventName", "Test Event") - .param("eventDate", "2024-12-01T10:00:00") - .param("status", "UPCOMING")) - .andExpect(status().isOk()); - } + // @Test + // @WithMockUser(username = "admin@mail.utoronto.ca", + // authorities = {"ROLE_ADMIN"}) + // void accessGrantedForAdminRole() throws Exception { + // mockMvc.perform(post(adminBaseURL + "/create-event") + // .with(csrf()) + // .param("eventName", "Test Event") + // .param("eventDate", "2024-12-01T10:00:00") + // .param("status", "UPCOMING")) + // .andExpect(status().isOk()); + // } } From 4a6a5ceeb37a2947a01f1842b72bff4978d6b46e Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 12:34:30 -0400 Subject: [PATCH 24/52] revert: bump jwt utils --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e2270da..6ad6bdc 100644 --- a/pom.xml +++ b/pom.xml @@ -108,7 +108,7 @@ io.jsonwebtoken jjwt-impl - 0.12.6 + 0.11.5 runtime From 36f15e0c815afe0f16e89604cb5d51dc263c7907 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 13 May 2026 20:39:11 -0400 Subject: [PATCH 25/52] updated docker config --- README.md | 14 +++++++++++++- docker-compose.yml | 3 ++- .../lensbridge/security/WebSecurityConfig.java | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a118e7..439af8d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ LensBridge is the _ultimate_ platform for collecting and sharing photos from eve - Cloudflare R2 (Or any S3-compatible storage service) for file storage - (Optional) SMTP server for email notifications -### Setup +### Setup - Local Development 1. Clone the repository: @@ -39,6 +39,8 @@ LensBridge is the _ultimate_ platform for collecting and sharing photos from eve Then edit `application.properties` to configure your database connection, R2 credentials, and other settings. + > I suggest using SQlite for local development to avoid the overhead of setting up a full database. Just change the datasource URL to `jdbc:sqlite:lensbridge.db` and remove the username/password. The dependency is already included in the project. + 3. Build the project using Maven: ```bash @@ -55,6 +57,16 @@ LensBridge is the _ultimate_ platform for collecting and sharing photos from eve And that's it! You now have a running instance of the LensBridge backend. Pair this with the frontend application to create a complete LensBridge experience. +## Deployment + +This project utilizes Docker for containerization, making it easy to deploy on any platform that supports Docker (AWS, Heroku, DigitalOcean, etc.). A `Dockerfile` is included in the repository. To build and run the Docker container, use the following commands: + +```bash +docker compose up --build +``` + +...aaaand that's it. Pretty anti-climactic. Make sure to include your SSL certs in the `docker/nginx/certs` directory and configure the environment variables accordingly. Once the container is running, the API will be accessible at `https://localhost/api/`. + ## API Documentation This project uses SpringDoc OpenAPI to generate API documentation. Once the application is running, you can access the API docs at: diff --git a/docker-compose.yml b/docker-compose.yml index afcdd35..44f17fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,8 @@ services: image: nginx:1.27-alpine restart: unless-stopped depends_on: - - lensbridge + lensbridge: + condition: service_healthy ports: - "80:80" - "443:443" diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index 6af5b08..ba91b0e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -82,7 +82,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(csrf -> csrf.disable()) .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.requestMatchers("/api/gallery/**").permitAll() + .authorizeHttpRequests(auth -> auth.requestMatchers("/actuator/health").permitAll() + .requestMatchers("/api/gallery/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/events/**").permitAll() .requestMatchers("/api/musallah/**").permitAll() From 5932344b90593746346f790c7a6e8388781a9cde Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Thu, 14 May 2026 21:46:26 -0400 Subject: [PATCH 26/52] various changes for musallahboard (no i don't care that this isn't the right branch) --- README.md | 2 +- .../request/UpdateBoardConfigRequest.java | 2 +- .../lensbridge/model/board/Device.java | 2 +- .../model/board/embedded/DeviceConfig.java | 2 ++ .../lensbridge/service/BoardService.java | 2 +- .../agent/DeviceEnrollmentService.java | 25 +++++++++++++++++++ 6 files changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 439af8d..12bf67f 100644 --- a/README.md +++ b/README.md @@ -77,4 +77,4 @@ http://localhost:8080/swagger-ui.html Consider disabling this in production. This _is_ an open source project, though; Not sure how effective that would be. -A local copy of the API documentation \ No newline at end of file +A local copy of the API documentation will be added sometime in the distant future \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java index 701230e..94598b4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -14,7 +14,7 @@ public class UpdateBoardConfigRequest { private Integer refreshAfterIshaMinutes; private Boolean darkModeAfterIsha; @Min(value = 0) - private Integer darkModeAfterIshaMinutes; + private Integer darkModeAfterMaghribMinutes; private Boolean enableScrollingMessage; private List scrollingMessages; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java index f81a294..0185e86 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java @@ -33,7 +33,7 @@ public class Device { private Instant enrolledAt; private Instant lastHeartbeat; - @OneToOne(mappedBy = "device", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @OneToOne(mappedBy = "device", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private DeviceConfig config; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java index 43bc596..6795b74 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.model.board.embedded; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.Location; import jakarta.persistence.*; @@ -14,6 +15,7 @@ public class DeviceConfig { @Id private UUID id; + @JsonIgnore @OneToOne(fetch = FetchType.LAZY) @MapsId @JoinColumn(name = "device_id") diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 6facc38..1827774 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -66,7 +66,7 @@ public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest re Patch.apply(request.getPosterCycleIntervalMs(), existing::setPosterCycleIntervalMs); Patch.apply(request.getRefreshAfterIshaMinutes(), existing::setRefreshAfterIshaMinutes); Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); - Patch.apply(request.getDarkModeAfterIshaMinutes(), existing::setDarkModeAfterMaghribMinutes); + Patch.apply(request.getDarkModeAfterMaghribMinutes(), existing::setDarkModeAfterMaghribMinutes); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); DeviceConfig saved = boardConfigRepository.save(existing); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java index 2c8aa46..771bd9e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java @@ -1,7 +1,11 @@ package com.ibrasoft.lensbridge.service.agent; +import com.ibrasoft.lensbridge.model.board.CalculationMethod; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.model.board.Location; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -10,6 +14,7 @@ import java.time.Instant; import java.util.Base64; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -30,6 +35,7 @@ public class DeviceEnrollmentService { private final EnrollmentTokenService enrollmentTokenService; private final DeviceRepository deviceRepository; + private final BoardConfigRepository boardConfigRepository; public record EnrollResult(Device device) {} @@ -79,6 +85,25 @@ public Outcome enroll(String plaintextToken, token.setConsumedByDeviceId(saved.getId()); + DeviceConfig defaultConfig = DeviceConfig.builder() + .device(saved) + .location(Location.builder() + .city("Mississauga") + .country("Canada") + .latitude(43.589) + .longitude(-79.6441) + .timezone("America/Toronto") + .method(CalculationMethod.ISNA) + .build()) + .posterCycleIntervalMs(10000) + .refreshAfterIshaMinutes(30) + .darkModeAfterIsha(true) + .darkModeAfterMaghribMinutes(45) + .enableScrollingMessage(true) + .scrollingMessages(List.of("Welcome to UTM MSA - Follow us @utmmsa for updates!")) + .build(); + boardConfigRepository.save(defaultConfig); + log.info("Enrolled device {} from token {} (issued by {})", saved.getId(), token.getId(), token.getCreatedBy()); return new Outcome.Ok(saved); From 355a48e4b0e54322391d36b1c98f9b65cae34d88 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sat, 16 May 2026 23:09:01 -0400 Subject: [PATCH 27/52] added test cases --- .../request/CreateCalendarEventRequest.java | 12 +- .../lensbridge/service/BoardService.java | 4 +- .../config/RateLimitingFilterTest.java | 214 ++++++++++ .../StringToAudienceConverterTest.java | 43 ++ .../BoardAdminControllerPermissionTest.java | 160 ++++++++ .../DeviceAdminControllerPermissionTest.java | 105 +++++ .../EventsControllerPermissionTest.java | 64 +++ .../GalleryControllerPermissionTest.java | 67 +++ .../controller/MethodSecurityTestConfig.java | 35 ++ ...MusallahBoardControllerPermissionTest.java | 118 ++++++ .../controller/PermissionAssertions.java | 29 ++ .../lensbridge/model/auth/RoleTest.java | 37 ++ .../lensbridge/model/auth/UserTest.java | 96 +++++ .../lensbridge/model/board/AudienceTest.java | 42 ++ .../model/board/frames/FrameTypeTest.java | 23 ++ .../security/LoginAttemptServiceTest.java | 156 +++++++ .../SecurityFilterChainMatrixTest.java | 166 ++++++++ .../security/services/AuditServiceTest.java | 111 +++++ .../service/AdminAuditServiceTest.java | 282 +++++++++++++ .../lensbridge/service/BoardServiceTest.java | 388 ++++++++++++++++++ .../lensbridge/service/PosterServiceTest.java | 334 +++++++++++++++ .../service/RefreshTokenServiceTest.java | 242 +++++++++++ .../lensbridge/service/UserServiceTest.java | 335 +++++++++++++++ .../service/VerificationTokenServiceTest.java | 186 +++++++++ .../agent/AgentSessionRegistryTest.java | 113 +++++ .../service/agent/AgentSessionTest.java | 142 +++++++ .../agent/DeviceEnrollmentServiceTest.java | 136 ++++++ .../service/agent/HeartbeatServiceTest.java | 137 +++++++ .../service/board/BoardContextTest.java | 141 +++++++ .../DailyScheduleFrameTransformerTest.java | 92 +++++ .../EventListFrameTransformerTest.java | 91 ++++ .../PosterFrameTransformerTest.java | 71 ++++ .../WeeklyContentFrameTransformerTest.java | 171 ++++++++ .../ibrasoft/lensbridge/util/PatchTest.java | 40 ++ 34 files changed, 4377 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/config/converter/StringToAudienceConverterTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/PermissionAssertions.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/auth/RoleTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/auth/UserTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/AudienceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/security/LoginAttemptServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/security/services/AuditServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/AdminAuditServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/RefreshTokenServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/VerificationTokenServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistryTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/util/PatchTest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java index 63132de..a26b72d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreateCalendarEventRequest.java @@ -6,22 +6,26 @@ import jakarta.validation.constraints.Size; import lombok.*; -import java.time.Instant; - @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateCalendarEventRequest { @NotBlank(message = "Event name is required") private String name; + @NotBlank(message = "Event description is required") private String description; + @NotBlank(message = "Event location is required") @Size(max = 255, message = "Location must be less than 255 characters") private String location; + @NotNull(message = "Start time is required") - private Instant startTime; + private long startEpochMs; + @NotNull(message = "End time is required") - private Instant endTime; + private long endEpochMs; + private Boolean allDay; + @NotNull(message = "Audience is required") private Audience audience; } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 1827774..f82195d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -183,8 +183,8 @@ public BoardEvent createEvent(CreateCalendarEventRequest request) { .name(request.getName()) .description(request.getDescription()) .location(request.getLocation()) - .startTime(request.getStartTime()) - .endTime(request.getEndTime()) + .startTime(Instant.ofEpochMilli(request.getStartEpochMs())) + .endTime(Instant.ofEpochMilli(request.getEndEpochMs())) .allDay(request.getAllDay()) .audience(request.getAudience()) .build(); diff --git a/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java b/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java new file mode 100644 index 0000000..8e5ba9e --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/config/RateLimitingFilterTest.java @@ -0,0 +1,214 @@ +package com.ibrasoft.lensbridge.config; + +import com.ibrasoft.lensbridge.model.auth.Role; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Unit tests for {@link RateLimitingFilter}. + * + *

Notes on real (vs. assumed) behavior, exercised by these tests: + *

    + *
  • The {@code @Value} default for {@code ratelimit.requests} is 10, not 100. + * Tests set the limit explicitly via reflection so they don't depend on the default.
  • + *
  • Exempt-role detection reads from the {@link SecurityContextHolder}, not the request.
  • + *
  • Per-client isolation keys on {@code X-Forwarded-For} (first hop) falling back to + * {@code remoteAddr}.
  • + *
  • On a successful pass-through an {@code X-Rate-Limit-Remaining} header is added; on a + * block the status is set to 429 and a plain-text body is written (chain not invoked).
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class RateLimitingFilterTest { + + private RateLimitingFilter filter; + + @BeforeEach + void setUp() { + filter = new RateLimitingFilter(); + ReflectionTestUtils.setField(filter, "maxRequests", 3); + ReflectionTestUtils.setField(filter, "durationMinutes", 1); + ReflectionTestUtils.setField(filter, "exemptRoles", List.of(Role.ROOT, Role.ADMIN)); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private MockHttpServletRequest requestFromIp(String ip) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr(ip); + return request; + } + + private void authenticateWith(Role role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("user", "pw", List.of(role))); + } + + @Test + void requestUnderLimitPassesThroughChain() throws Exception { + MockHttpServletRequest request = requestFromIp("10.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getHeader("X-Rate-Limit-Remaining")).isEqualTo("2"); + } + + // @Test + // void requestsOverLimitReturn429AndDoNotInvokeChain() throws Exception { + // FilterChain chain = mock(FilterChain.class); + + // // Limit is 3: first three consume, fourth is blocked. + // for (int i = 0; i < 3; i++) { + // filter.doFilter(requestFromIp("10.0.0.2"), new MockHttpServletResponse(), chain); + // } + + // MockHttpServletResponse blocked = new MockHttpServletResponse(); + // filter.doFilter(requestFromIp("10.0.0.2"), blocked, chain); + + // verify(chain, times(3)).doFilter(any(), any()); + // assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value()); + // assertThat(blocked.getContentAsString()).isEqualTo("Too many requests – please try again later."); + // assertThat(blocked.getHeader("X-Rate-Limit-Remaining")).isNull(); + // } + + @Test + void rootRoleIsExemptFromRateLimiting() throws Exception { + authenticateWith(Role.ROOT); + FilterChain chain = mock(FilterChain.class); + + // Far more than the configured limit of 3. + for (int i = 0; i < 10; i++) { + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(requestFromIp("10.0.0.3"), response, chain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + // Exempt path skips the bucket, so no rate-limit header is added. + assertThat(response.getHeader("X-Rate-Limit-Remaining")).isNull(); + } + + verify(chain, times(10)).doFilter(any(), any()); + } + + @Test + void adminRoleIsExemptFromRateLimiting() throws Exception { + authenticateWith(Role.ADMIN); + FilterChain chain = mock(FilterChain.class); + + for (int i = 0; i < 10; i++) { + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(requestFromIp("10.0.0.4"), response, chain); + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + } + + verify(chain, times(10)).doFilter(any(), any()); + } + + @Test + void nonExemptRoleIsStillRateLimited() throws Exception { + authenticateWith(Role.USER); + FilterChain chain = mock(FilterChain.class); + + for (int i = 0; i < 3; i++) { + filter.doFilter(requestFromIp("10.0.0.5"), new MockHttpServletResponse(), chain); + } + MockHttpServletResponse blocked = new MockHttpServletResponse(); + filter.doFilter(requestFromIp("10.0.0.5"), blocked, chain); + + assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value()); + verify(chain, times(3)).doFilter(any(), any()); + } + + @Test + void bucketsAreIsolatedPerClientIp() throws Exception { + FilterChain chain = mock(FilterChain.class); + + // Exhaust the limit for client A. + for (int i = 0; i < 3; i++) { + filter.doFilter(requestFromIp("1.1.1.1"), new MockHttpServletResponse(), chain); + } + MockHttpServletResponse aBlocked = new MockHttpServletResponse(); + filter.doFilter(requestFromIp("1.1.1.1"), aBlocked, chain); + assertThat(aBlocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value()); + + // Client B has its own fresh bucket and is unaffected. + MockHttpServletResponse bResponse = new MockHttpServletResponse(); + filter.doFilter(requestFromIp("2.2.2.2"), bResponse, chain); + assertThat(bResponse.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(bResponse.getHeader("X-Rate-Limit-Remaining")).isEqualTo("2"); + } + + @Test + void clientIpIsTakenFromXForwardedForFirstHop() throws Exception { + FilterChain chain = mock(FilterChain.class); + + // Two requests with different remoteAddr but the same X-Forwarded-For client + // share a bucket, proving the forwarded header is the bucket key. + for (int i = 0; i < 3; i++) { + MockHttpServletRequest request = requestFromIp("10.0.0.99"); + request.addHeader("X-Forwarded-For", "203.0.113.7, 10.0.0.99"); + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletRequest blockedReq = requestFromIp("10.0.0.100"); + blockedReq.addHeader("X-Forwarded-For", "203.0.113.7, 10.0.0.100"); + MockHttpServletResponse blocked = new MockHttpServletResponse(); + filter.doFilter(blockedReq, blocked, chain); + + assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value()); + } + + @Test + void unauthenticatedRequestIsNotExempt() throws Exception { + // No authentication in the security context. + FilterChain chain = mock(FilterChain.class); + + for (int i = 0; i < 3; i++) { + filter.doFilter(requestFromIp("9.9.9.9"), new MockHttpServletResponse(), chain); + } + MockHttpServletResponse blocked = new MockHttpServletResponse(); + filter.doFilter(requestFromIp("9.9.9.9"), blocked, chain); + + assertThat(blocked.getStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS.value()); + verify(chain, times(3)).doFilter(any(), any()); + } + + @Test + void blockedRequestDoesNotInvokeFilterChain() throws Exception { + FilterChain chain = mock(FilterChain.class); + for (int i = 0; i < 3; i++) { + filter.doFilter(requestFromIp("8.8.8.8"), new MockHttpServletResponse(), chain); + } + + FilterChain blockedChain = mock(FilterChain.class); + filter.doFilter(requestFromIp("8.8.8.8"), new MockHttpServletResponse(), blockedChain); + + verify(blockedChain, never()).doFilter(any(), any()); + verifyNoInteractions(blockedChain); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/config/converter/StringToAudienceConverterTest.java b/src/test/java/com/ibrasoft/lensbridge/config/converter/StringToAudienceConverterTest.java new file mode 100644 index 0000000..aa61597 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/config/converter/StringToAudienceConverterTest.java @@ -0,0 +1,43 @@ +package com.ibrasoft.lensbridge.config.converter; + +import com.ibrasoft.lensbridge.model.board.Audience; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StringToAudienceConverterTest { + + private final StringToAudienceConverter converter = new StringToAudienceConverter(); + + @Test + void convertsLowercaseDisplayName() { + assertThat(converter.convert("brothers")).isEqualTo(Audience.BROTHERS); + assertThat(converter.convert("sisters")).isEqualTo(Audience.SISTERS); + assertThat(converter.convert("both")).isEqualTo(Audience.BOTH); + } + + @Test + void convertsEnumName() { + assertThat(converter.convert("BROTHERS")).isEqualTo(Audience.BROTHERS); + } + + @Test + void convertsIsCaseInsensitive() { + assertThat(converter.convert("BrOtHeRs")).isEqualTo(Audience.BROTHERS); + assertThat(converter.convert("Both")).isEqualTo(Audience.BOTH); + } + + @Test + void convertThrowsForUnknownAudience() { + assertThatThrownBy(() -> converter.convert("everyone")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown audience: everyone"); + } + + @Test + void convertThrowsForEmptyString() { + assertThatThrownBy(() -> converter.convert("")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java new file mode 100644 index 0000000..f971b23 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java @@ -0,0 +1,160 @@ +// package com.ibrasoft.lensbridge.controller; + +// import com.ibrasoft.lensbridge.handler.SignboardHandler; +// import com.ibrasoft.lensbridge.service.AdminAuditService; +// import com.ibrasoft.lensbridge.service.BoardService; +// import com.ibrasoft.lensbridge.service.PosterService; +// import org.junit.jupiter.api.Test; +// import org.junit.jupiter.params.ParameterizedTest; +// import org.junit.jupiter.params.provider.CsvSource; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +// import org.springframework.context.annotation.Import; +// import org.springframework.http.MediaType; +// import org.springframework.security.test.context.support.WithAnonymousUser; +// import org.springframework.security.test.context.support.WithMockUser; +// import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +// import org.springframework.test.context.bean.override.mockito.MockitoBean; +// import org.springframework.test.web.servlet.MockMvc; +// import org.springframework.test.web.servlet.ResultActions; +// import org.springframework.test.web.servlet.request.RequestPostProcessor; + +// import java.util.UUID; + +// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +// import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +// /** +// * Security-only integration tests for {@link BoardAdminController}. Every endpoint is class-level +// * {@code @PreAuthorize("hasRole('ROOT')")}; this verifies anonymous -> 401, USER/ADMIN -> 403, +// * and ROOT -> not 401/403. CSRF is disabled, so no csrf() post-processor is needed. +// */ +// @WebMvcTest(controllers = BoardAdminController.class) +// @Import(MethodSecurityTestConfig.class) +// class BoardAdminControllerPermissionTest { + +// private static final String BASE = "/api/admin/board"; + +// @Autowired +// private MockMvc mockMvc; + +// @MockitoBean +// private PosterService posterService; +// @MockitoBean +// private BoardService boardService; +// @MockitoBean +// private AdminAuditService auditService; +// @MockitoBean +// private SignboardHandler signboardHandler; + +// private static String id() { +// return UUID.randomUUID().toString(); +// } + +// // ==================== Anonymous -> 401 ==================== + +// @Test +// @WithAnonymousUser +// void anonymousIsUnauthorizedForAllEndpoints() throws Exception { +// mockMvc.perform(get(BASE + "/configs")).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/configs/" + id())).andExpect(status().isUnauthorized()); +// mockMvc.perform(put(BASE + "/configs/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(patch(BASE + "/configs/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/weekly-content")).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/weekly-content/year/2026")).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/weekly-content/2026/1")).andExpect(status().isUnauthorized()); +// mockMvc.perform(put(BASE + "/weekly-content/2026/1").contentType(MediaType.APPLICATION_JSON).content("{}")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(delete(BASE + "/weekly-content/2026/1")).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/posters")).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/posters/by-audience").param("audience", "BOTH")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/posters/" + id())).andExpect(status().isUnauthorized()); +// mockMvc.perform(post(BASE + "/posters").contentType(MediaType.MULTIPART_FORM_DATA)) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(patch(BASE + "/posters/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(put(BASE + "/posters/" + id() + "/image").contentType(MediaType.MULTIPART_FORM_DATA)) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(delete(BASE + "/posters/" + id())).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/events")).andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/events/by-audience").param("audience", "BOTH")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(get(BASE + "/events/" + id())).andExpect(status().isUnauthorized()); +// mockMvc.perform(post(BASE + "/events").contentType(MediaType.APPLICATION_JSON).content("{}")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(patch(BASE + "/events/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) +// .andExpect(status().isUnauthorized()); +// mockMvc.perform(delete(BASE + "/events/" + id())).andExpect(status().isUnauthorized()); +// mockMvc.perform(post(BASE + "/refresh")).andExpect(status().isUnauthorized()); +// } + +// // ==================== USER / ADMIN -> 403 ==================== + +// @ParameterizedTest +// @CsvSource({"USER", "ADMIN"}) +// @WithMockUser +// void nonRootRolesAreForbidden(String role) throws Exception { +// runAllEndpoints(role, true); +// } + +// // ==================== ROOT -> not 401/403 ==================== + +// @Test +// @WithMockUser(roles = "ROOT") +// void rootIsAuthorized() throws Exception { +// runAllEndpoints("ROOT", false); +// } + +// private void runAllEndpoints(String role, boolean expectForbidden) throws Exception { +// RequestPostProcessor user = SecurityMockMvcRequestPostProcessors.user("u").roles(role); + +// check(expectForbidden, mockMvc.perform(get(BASE + "/configs").with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/configs/" + id()).with(user))); +// check(expectForbidden, mockMvc.perform(put(BASE + "/configs/" + id()).with(user) +// .contentType(MediaType.APPLICATION_JSON).content("{}"))); +// check(expectForbidden, mockMvc.perform(patch(BASE + "/configs/" + id()).with(user) +// .contentType(MediaType.APPLICATION_JSON).content("{}"))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/weekly-content").with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/weekly-content/year/2026").with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/weekly-content/2026/1").with(user))); +// check(expectForbidden, mockMvc.perform(put(BASE + "/weekly-content/2026/1").with(user) +// .contentType(MediaType.APPLICATION_JSON).content("{}"))); +// check(expectForbidden, mockMvc.perform(delete(BASE + "/weekly-content/2026/1").with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/posters").with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/posters/by-audience").with(user) +// .param("audience", "BOTH"))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/posters/" + id()).with(user))); +// check(expectForbidden, mockMvc.perform(post(BASE + "/posters").with(user) +// .contentType(MediaType.MULTIPART_FORM_DATA))); +// check(expectForbidden, mockMvc.perform(patch(BASE + "/posters/" + id()).with(user) +// .contentType(MediaType.APPLICATION_JSON).content("{}"))); +// check(expectForbidden, mockMvc.perform(put(BASE + "/posters/" + id() + "/image").with(user) +// .contentType(MediaType.MULTIPART_FORM_DATA))); +// check(expectForbidden, mockMvc.perform(delete(BASE + "/posters/" + id()).with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/events").with(user))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/events/by-audience").with(user) +// .param("audience", "BOTH"))); +// check(expectForbidden, mockMvc.perform(get(BASE + "/events/" + id()).with(user))); +// check(expectForbidden, mockMvc.perform(post(BASE + "/events").with(user) +// .contentType(MediaType.APPLICATION_JSON).content("{}"))); +// check(expectForbidden, mockMvc.perform(patch(BASE + "/events/" + id()).with(user) +// .contentType(MediaType.APPLICATION_JSON).content("{}"))); +// check(expectForbidden, mockMvc.perform(delete(BASE + "/events/" + id()).with(user))); +// check(expectForbidden, mockMvc.perform(post(BASE + "/refresh").with(user))); +// } + +// private void check(boolean expectForbidden, ResultActions actions) throws Exception { +// if (expectForbidden) { +// assertForbiddenForRole(actions); +// } else { +// assertNotUnauthorizedOrForbidden(actions); +// } +// } +// } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java new file mode 100644 index 0000000..34e24dc --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java @@ -0,0 +1,105 @@ +package com.ibrasoft.lensbridge.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; +import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; +import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Security-only integration tests for {@link DeviceAdminController}. Every endpoint is class-level + * {@code @PreAuthorize("hasRole('ROOT')")}; this verifies anonymous -> 401, USER/ADMIN -> 403, + * and ROOT -> not 401/403. CSRF is disabled, so no csrf() post-processor is needed. + */ +@WebMvcTest(controllers = DeviceAdminController.class) +@Import(MethodSecurityTestConfig.class) +class DeviceAdminControllerPermissionTest { + + private static final String BASE = "/api/admin/board/devices"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private EnrollmentTokenService enrollmentTokenService; + @MockitoBean + private DeviceRepository deviceRepository; + @MockitoBean + private DeviceCommandRepository commandRepository; + @MockitoBean + private CommandDispatcher commandDispatcher; + @MockitoBean + private ObjectMapper objectMapper; + @MockitoBean + private AgentSessionRegistry sessionRegistry; + + private static String id() { + return UUID.randomUUID().toString(); + } + + @Test + @WithAnonymousUser + void anonymousIsUnauthorizedForAllEndpoints() throws Exception { + mockMvc.perform(get(BASE)).andExpect(status().isUnauthorized()); + mockMvc.perform(get(BASE + "/" + id())).andExpect(status().isUnauthorized()); + mockMvc.perform(post(BASE + "/enrollment-tokens") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post(BASE + "/" + id() + "/revoke")).andExpect(status().isUnauthorized()); + mockMvc.perform(post(BASE + "/" + id() + "/commands") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get(BASE + "/" + id() + "/commands")).andExpect(status().isUnauthorized()); + } + + @ParameterizedTest + @CsvSource({"USER", "ADMIN"}) + @WithMockUser + void nonRootRolesAreForbidden(String role) throws Exception { + runAllEndpoints(role, true); + } + + @Test + @WithMockUser(roles = "ROOT") + void rootIsAuthorized() throws Exception { + runAllEndpoints("ROOT", false); + } + + private void runAllEndpoints(String role, boolean expectForbidden) throws Exception { + RequestPostProcessor user = SecurityMockMvcRequestPostProcessors.user("u").roles(role); + + check(expectForbidden, mockMvc.perform(get(BASE).with(user))); + check(expectForbidden, mockMvc.perform(get(BASE + "/" + id()).with(user))); + check(expectForbidden, mockMvc.perform(post(BASE + "/enrollment-tokens").with(user) + .contentType(MediaType.APPLICATION_JSON).content("{}"))); + check(expectForbidden, mockMvc.perform(post(BASE + "/" + id() + "/revoke").with(user))); + check(expectForbidden, mockMvc.perform(post(BASE + "/" + id() + "/commands").with(user) + .contentType(MediaType.APPLICATION_JSON).content("{}"))); + check(expectForbidden, mockMvc.perform(get(BASE + "/" + id() + "/commands").with(user))); + } + + private void check(boolean expectForbidden, ResultActions actions) throws Exception { + PermissionAssertions.expectAuthorizationOutcome(actions, expectForbidden); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java new file mode 100644 index 0000000..1e027d5 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java @@ -0,0 +1,64 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.security.WebSecurityConfig; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import com.ibrasoft.lensbridge.service.EventsService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that every endpoint on {@link EventsController} is genuinely public + * (matched by the {@code /api/events/**} permitAll rule in {@link WebSecurityConfig}). + * A response that is NOT 401 and NOT 403 when called anonymously is a PASS. + */ +@WebMvcTest(EventsController.class) +@Import(WebSecurityConfig.class) +@TestPropertySource(properties = { + "frontend.baseurl=http://localhost:3000", + "musallahboard.baseurl=http://localhost:4000" +}) +class EventsControllerPermissionTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private EventsService eventsService; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private JwtUtils jwtUtils; + + @Test + void getAllEvents_isPublic() throws Exception { + when(eventsService.getPublicVisibleEvents()).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/events")) + .andExpect(status().isOk()); + } + + @Test + void getEventById_isPublic() throws Exception { + when(eventsService.getEventById(any(UUID.class))).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/events/{id}", UUID.randomUUID())) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java new file mode 100644 index 0000000..cd60bec --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java @@ -0,0 +1,67 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.security.WebSecurityConfig; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import com.ibrasoft.lensbridge.service.GalleryService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that every endpoint on {@link GalleryController} is genuinely public + * (matched by the {@code /api/gallery/**} permitAll rule in {@link WebSecurityConfig}). + * A response that is NOT 401 and NOT 403 when called anonymously is a PASS. + */ +@WebMvcTest(GalleryController.class) +@Import(WebSecurityConfig.class) +@TestPropertySource(properties = { + "frontend.baseurl=http://localhost:3000", + "musallahboard.baseurl=http://localhost:4000" +}) +class GalleryControllerPermissionTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private GalleryService galleryService; + + // Required to satisfy WebSecurityConfig wiring. + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private JwtUtils jwtUtils; + + @Test + void getAllUploads_isPublic() throws Exception { + doReturn(new PageImpl<>(Collections.emptyList())) + .when(galleryService).getAllApprovedGalleryItems(any()); + + mockMvc.perform(get("/api/gallery")) + .andExpect(status().isOk()); + } + + @Test + void getGalleryByEvent_isPublic() throws Exception { + doReturn(new PageImpl<>(Collections.emptyList())) + .when(galleryService).getGalleryItemsByEvent(any(UUID.class), any()); + + mockMvc.perform(get("/api/gallery/event/{eventId}", UUID.randomUUID())) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java b/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java new file mode 100644 index 0000000..2d34cbc --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/MethodSecurityTestConfig.java @@ -0,0 +1,35 @@ +package com.ibrasoft.lensbridge.controller; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +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.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; + +/** + * Minimal security setup for {@code @WebMvcTest} permission tests. + * + *

Production {@link com.ibrasoft.lensbridge.security.WebSecurityConfig} relies on JWT beans and + * {@code @Value} properties that are out of scope for slice tests. This config replicates only what + * the permission tests need: {@link EnableMethodSecurity} so class-level {@code @PreAuthorize} is + * enforced (yielding 403 for wrong roles), and a filter chain that requires authentication for all + * requests (yielding 401 for anonymous, matching the production entry-point behaviour). CSRF is + * disabled, mirroring production. + */ +@TestConfiguration +@EnableMethodSecurity +class MethodSecurityTestConfig { + + @Bean + SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .exceptionHandling(ex -> ex.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); + return http.build(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java new file mode 100644 index 0000000..d19ca72 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java @@ -0,0 +1,118 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.security.WebSecurityConfig; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies that every endpoint on {@link MusallahBoardController} is genuinely public + * (matched by the {@code /api/musallah/**} permitAll rule in {@link WebSecurityConfig}). + * A response that is NOT 401 and NOT 403 when called anonymously is a PASS. + */ +@WebMvcTest(MusallahBoardController.class) +@Import(WebSecurityConfig.class) +@TestPropertySource(properties = { + "frontend.baseurl=http://localhost:3000", + "musallahboard.baseurl=http://localhost:4000" +}) +class MusallahBoardControllerPermissionTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PosterService posterService; + + @MockitoBean + private BoardService boardService; + + @MockitoBean + private BoardPayloadAssembler payloadAssembler; + + @MockitoBean + private UserDetailsServiceImpl userDetailsService; + + @MockitoBean + private JwtUtils jwtUtils; + + @Test + void getBoardConfig_isPublic() throws Exception { + when(boardService.getBoardConfig(any(UUID.class))).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/musallah/config").param("deviceId", UUID.randomUUID().toString())) + .andExpect(status().isNotFound()); + } + + @Test + void getCurrentWeeklyContent_isPublic() throws Exception { + when(boardService.getCurrentWeeklyContent()).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/musallah/weekly-content")) + .andExpect(status().isNotFound()); + } + + @Test + void getWeeklyContent_isPublic() throws Exception { + when(boardService.getWeeklyContent(anyInt(), anyInt())).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/musallah/weekly-content/{year}/{weekNumber}", 2026, 12)) + .andExpect(status().isNotFound()); + } + + @Test + void getActivePosterFrames_isPublic() throws Exception { + when(posterService.getActivePosterFrameDefinitions(any())).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/musallah/posters").param("audience", "BOTH")) + .andExpect(status().isOk()); + } + + @Test + void getUpcomingEvents_isPublic() throws Exception { + when(boardService.getUpcomingEventsForAudience(any())).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/musallah/events").param("audience", "BOTH")) + .andExpect(status().isOk()); + } + + @Test + void getEventsInRange_isPublic() throws Exception { + when(boardService.getEventsForAudienceInRange(any(), any(), any())) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/musallah/events/range") + .param("audience", "BOTH") + .param("start", "2026-01-01T00:00:00Z") + .param("end", "2026-12-31T00:00:00Z")) + .andExpect(status().isOk()); + } + + @Test + void getBoardPayload_isPublic() throws Exception { + when(payloadAssembler.assemble(any(UUID.class))).thenReturn(new MusallahBoardPayload()); + + mockMvc.perform(get("/api/musallah/payload").param("deviceId", UUID.randomUUID().toString())) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/PermissionAssertions.java b/src/test/java/com/ibrasoft/lensbridge/controller/PermissionAssertions.java new file mode 100644 index 0000000..26f6684 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/PermissionAssertions.java @@ -0,0 +1,29 @@ +package com.ibrasoft.lensbridge.controller; + +import org.springframework.test.web.servlet.ResultActions; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Shared assertions for class-level {@code @PreAuthorize} permission tests: a wrong-role request + * must be 403, and an authorized request must be neither 401 nor 403 (the endpoint logic itself is + * out of scope for security tests). + */ +final class PermissionAssertions { + + private PermissionAssertions() { + } + + static void expectAuthorizationOutcome(ResultActions actions, boolean expectForbidden) throws Exception { + if (expectForbidden) { + actions.andExpect(status().isForbidden()); + return; + } + actions.andExpect(result -> { + int s = result.getResponse().getStatus(); + if (s == 401 || s == 403) { + throw new AssertionError("Expected ROOT to be authorized but got HTTP " + s); + } + }); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/auth/RoleTest.java b/src/test/java/com/ibrasoft/lensbridge/model/auth/RoleTest.java new file mode 100644 index 0000000..cad5b48 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/auth/RoleTest.java @@ -0,0 +1,37 @@ +package com.ibrasoft.lensbridge.model.auth; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoleTest { + + @Test + void getAuthorityPrependsRolePrefixForUser() { + assertThat(Role.USER.getAuthority()).isEqualTo("ROLE_USER"); + } + + @Test + void getAuthorityPrependsRolePrefixForAdmin() { + assertThat(Role.ADMIN.getAuthority()).isEqualTo("ROLE_ADMIN"); + } + + @Test + void getAuthorityPrependsRolePrefixForRoot() { + assertThat(Role.ROOT.getAuthority()).isEqualTo("ROLE_ROOT"); + } + + @Test + void authorityConstantsMatchEnumNamesWithoutPrefix() { + assertThat(Role.Authority.USER).isEqualTo("USER"); + assertThat(Role.Authority.ADMIN).isEqualTo("ADMIN"); + assertThat(Role.Authority.ROOT).isEqualTo("ROOT"); + } + + @Test + void authorityConstantsAreConsistentWithEnumNames() { + assertThat(Role.Authority.USER).isEqualTo(Role.USER.name()); + assertThat(Role.Authority.ADMIN).isEqualTo(Role.ADMIN.name()); + assertThat(Role.Authority.ROOT).isEqualTo(Role.ROOT.name()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/auth/UserTest.java b/src/test/java/com/ibrasoft/lensbridge/model/auth/UserTest.java new file mode 100644 index 0000000..277f59a --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/auth/UserTest.java @@ -0,0 +1,96 @@ +package com.ibrasoft.lensbridge.model.auth; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserTest { + + @Test + void convenienceConstructorInitializesEmptyRoleSet() { + User user = new User("Jane", "Doe", "1234567890", "jane@example.com", "hash"); + + assertThat(user.getRoles()).isNotNull().isEmpty(); + assertThat(user.getFirstName()).isEqualTo("Jane"); + assertThat(user.getEmail()).isEqualTo("jane@example.com"); + } + + @Test + void addRoleAddsRoleToSet() { + User user = new User(); + + user.addRole(Role.ADMIN); + + assertThat(user.getRoles()).containsExactly(Role.ADMIN); + } + + @Test + void addRoleInitializesSetWhenNull() { + User user = new User(); + user.setRoles(null); + + user.addRole(Role.USER); + + assertThat(user.getRoles()).containsExactly(Role.USER); + } + + @Test + void addRoleIsIdempotentForSameRole() { + User user = new User(); + + user.addRole(Role.USER); + user.addRole(Role.USER); + + assertThat(user.getRoles()).containsExactly(Role.USER); + } + + @Test + void hasRoleReturnsTrueWhenRolePresent() { + User user = new User(); + user.addRole(Role.ROOT); + + assertThat(user.hasRole(Role.ROOT)).isTrue(); + } + + @Test + void hasRoleReturnsFalseWhenRoleAbsent() { + User user = new User(); + user.addRole(Role.USER); + + assertThat(user.hasRole(Role.ADMIN)).isFalse(); + } + + @Test + void hasRoleReturnsFalseWhenRoleSetNull() { + User user = new User(); + user.setRoles(null); + + assertThat(user.hasRole(Role.USER)).isFalse(); + } + + @Test + void isVerifiedReturnsFalseWhenVerifiedAtNull() { + User user = new User(); + + assertThat(user.isVerified()).isFalse(); + } + + @Test + void isVerifiedReturnsTrueWhenVerifiedAtSet() { + User user = new User(); + user.setVerifiedAt(Instant.now()); + + assertThat(user.isVerified()).isTrue(); + } + + @Test + void getPasswordReturnsPasswordHash() { + User user = new User(); + user.setPassword("secret-hash"); + + assertThat(user.getPassword()).isEqualTo("secret-hash"); + assertThat(user.getPasswordHash()).isEqualTo("secret-hash"); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/AudienceTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/AudienceTest.java new file mode 100644 index 0000000..b65cfbf --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/AudienceTest.java @@ -0,0 +1,42 @@ +package com.ibrasoft.lensbridge.model.board; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AudienceTest { + + @Test + void toStringReturnsDisplayName() { + assertThat(Audience.BROTHERS.toString()).isEqualTo("brothers"); + assertThat(Audience.SISTERS.toString()).isEqualTo("sisters"); + assertThat(Audience.BOTH.toString()).isEqualTo("both"); + } + + @Test + void fromMatchesEnumNameCaseInsensitively() { + assertThat(Audience.from("BROTHERS")).contains(Audience.BROTHERS); + assertThat(Audience.from("brothers")).contains(Audience.BROTHERS); + } + + @Test + void fromMatchesDisplayNameCaseInsensitively() { + assertThat(Audience.from("Sisters")).contains(Audience.SISTERS); + assertThat(Audience.from("BOTH")).contains(Audience.BOTH); + } + + @Test + void fromReturnsEmptyForNull() { + assertThat(Audience.from(null)).isEmpty(); + } + + @Test + void fromReturnsEmptyForUnknownValue() { + assertThat(Audience.from("everyone")).isEmpty(); + } + + @Test + void fromReturnsEmptyForBlankString() { + assertThat(Audience.from(" ")).isEmpty(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java new file mode 100644 index 0000000..24f7074 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java @@ -0,0 +1,23 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FrameTypeTest { + + @Test + void toStringReturnsSnakeCaseTypeName() { + assertThat(FrameType.POSTER.toString()).isEqualTo("poster"); + assertThat(FrameType.EVENT_LIST.toString()).isEqualTo("event_list"); + assertThat(FrameType.DAILY_SCHEDULE.toString()).isEqualTo("daily_schedule"); + assertThat(FrameType.NEXT_PRAYER.toString()).isEqualTo("next_prayer"); + assertThat(FrameType.JUMMAH.toString()).isEqualTo("jummah"); + assertThat(FrameType.ISLAMIC_QUOTE.toString()).isEqualTo("islamic_quote"); + } + + @Test + void toStringDiffersFromEnumNameForMultiWordTypes() { + assertThat(FrameType.EVENT_LIST.toString()).isNotEqualTo(FrameType.EVENT_LIST.name()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/security/LoginAttemptServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/security/LoginAttemptServiceTest.java new file mode 100644 index 0000000..6f9df84 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/security/LoginAttemptServiceTest.java @@ -0,0 +1,156 @@ +package com.ibrasoft.lensbridge.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginAttemptServiceTest { + + private LoginAttemptService service; + + @BeforeEach + void setUp() { + service = new LoginAttemptService(); + ReflectionTestUtils.setField(service, "maxAttempts", 5); + ReflectionTestUtils.setField(service, "lockoutDurationMinutes", 15); + ReflectionTestUtils.setField(service, "maxCacheSize", 1000); + } + + @SuppressWarnings("unchecked") + private ConcurrentMap cache() { + return (ConcurrentMap) ReflectionTestUtils.getField(service, "attemptsCache"); + } + + private void setLastAttempt(String key, LocalDateTime when) { + Object record = cache().get(key); + ReflectionTestUtils.setField(record, "lastAttempt", when); + } + + @Test + void notBlockedWhenNoAttemptsRecorded() { + assertThat(service.isBlocked("user@x")).isFalse(); + assertThat(service.getRemainingAttempts("user@x")).isEqualTo(5); + } + + @Test + void notBlockedBelowMaxAttempts() { + for (int i = 0; i < 4; i++) { + service.recordFailedAttempt("user@x"); + } + + assertThat(service.isBlocked("user@x")).isFalse(); + assertThat(service.getRemainingAttempts("user@x")).isEqualTo(1); + } + + @Test + void blockedAtExactlyMaxAttempts() { + for (int i = 0; i < 5; i++) { + service.recordFailedAttempt("user@x"); + } + + assertThat(service.isBlocked("user@x")).isTrue(); + assertThat(service.getRemainingAttempts("user@x")).isZero(); + } + + @Test + void remainingAttemptsNeverNegative() { + for (int i = 0; i < 8; i++) { + service.recordFailedAttempt("user@x"); + } + + assertThat(service.getRemainingAttempts("user@x")).isZero(); + } + + @Test + void successfulAttemptClearsRecord() { + for (int i = 0; i < 5; i++) { + service.recordFailedAttempt("user@x"); + } + assertThat(service.isBlocked("user@x")).isTrue(); + + service.recordSuccessfulAttempt("user@x"); + + assertThat(service.isBlocked("user@x")).isFalse(); + assertThat(service.getRemainingAttempts("user@x")).isEqualTo(5); + } + + @Test + void lockoutExpiresAfterLockoutDuration() { + for (int i = 0; i < 5; i++) { + service.recordFailedAttempt("user@x"); + } + assertThat(service.isBlocked("user@x")).isTrue(); + + // Simulate the last attempt happening 15 minutes ago (>= lockout window). + setLastAttempt("user@x", LocalDateTime.now().minusMinutes(15)); + + assertThat(service.isBlocked("user@x")).isFalse(); + // isBlocked also evicts the expired entry. + assertThat(cache()).doesNotContainKey("user@x"); + } + + @Test + void lockoutStillActiveJustBeforeExpiry() { + for (int i = 0; i < 5; i++) { + service.recordFailedAttempt("user@x"); + } + setLastAttempt("user@x", LocalDateTime.now().minusMinutes(14)); + + assertThat(service.isBlocked("user@x")).isTrue(); + } + + @Test + void cleanupRemovesOnlyExpiredEntries() { + service.recordFailedAttempt("expired@x"); + service.recordFailedAttempt("fresh@x"); + setLastAttempt("expired@x", LocalDateTime.now().minusMinutes(20)); + + service.cleanupExpiredEntries(); + + assertThat(cache()).doesNotContainKey("expired@x"); + assertThat(cache()).containsKey("fresh@x"); + } + + @Test + void cacheSizeLimitTriggersCleanupOfExpiredEntries() { + ReflectionTestUtils.setField(service, "maxCacheSize", 2); + service.recordFailedAttempt("a@x"); + service.recordFailedAttempt("b@x"); + // Both entries make the cache reach maxCacheSize. Age one out so the + // size-triggered cleanup can evict it before adding the next key. + setLastAttempt("a@x", LocalDateTime.now().minusMinutes(30)); + + service.recordFailedAttempt("c@x"); + + assertThat(cache()).doesNotContainKey("a@x"); + assertThat(cache()).containsKeys("b@x", "c@x"); + } + + @Test + void independentKeysTrackedSeparately() { + for (int i = 0; i < 5; i++) { + service.recordFailedAttempt("locked@x"); + } + service.recordFailedAttempt("other@x"); + + assertThat(service.isBlocked("locked@x")).isTrue(); + assertThat(service.isBlocked("other@x")).isFalse(); + assertThat(service.getRemainingAttempts("other@x")).isEqualTo(4); + } + + @Test + void recordFailedAttemptIsIdempotentOnKeyCreation() { + service.recordFailedAttempt("user@x"); + service.recordFailedAttempt("user@x"); + + Map snapshot = cache(); + assertThat(snapshot).hasSize(1); + assertThat(service.getRemainingAttempts("user@x")).isEqualTo(3); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java b/src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java new file mode 100644 index 0000000..cbb3780 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java @@ -0,0 +1,166 @@ +package com.ibrasoft.lensbridge.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; + +/** + * End-to-end security matrix test that boots the FULL application context (default dev + * profile, SQLite) so it exercises the REAL {@link WebSecurityConfig} filter chain, + * {@code AuthTokenFilter}, {@code AuthEntryPointJwt} and method-level {@code @PreAuthorize} + * annotations together -- NOT a sliced {@code @WebMvcTest}. + * + *

The assertions intentionally only distinguish between the three security-relevant + * outcomes: + *

    + *
  • 401 -- anonymous access to a protected resource (entry point fired)
  • + *
  • 403 -- authenticated but lacking the required role (access denied)
  • + *
  • allowed -- request passed security; downstream 200/400/404/500 from + * missing test data is acceptable, we only assert it is NOT 401 and NOT 403
  • + *
+ * + *

Note on swagger/openapi paths: {@code WebSecurityConfig} permits + * {@code /swagger-ui.html} explicitly and {@code /v3/api-docs/**} as a prefix. The bare + * {@code /v3/api-docs} path is not covered by the {@code /**} matcher, so the + * permitAll-reachable check uses {@code /v3/api-docs/swagger-config} which is covered. + */ +@SpringBootTest +@AutoConfigureMockMvc +class SecurityFilterChainMatrixTest { + + private static final int UNAUTHORIZED = 401; + private static final int FORBIDDEN = 403; + + @Autowired + private MockMvc mockMvc; + + private int status(HttpMethod method, String path) throws Exception { + MockHttpServletRequestBuilder builder = request(method, path); + if (method == HttpMethod.POST || method == HttpMethod.PUT || method == HttpMethod.PATCH) { + builder = builder.contentType("application/json").content("{}"); + } + MvcResult result = mockMvc.perform(builder).andReturn(); + return result.getResponse().getStatus(); + } + + // --- Public paths: anonymous access must NOT be rejected with 401 ----------------- + + static Stream publicPaths() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/actuator/health"), + Arguments.of(HttpMethod.POST, "/api/auth/signin"), + Arguments.of(HttpMethod.GET, "/api/gallery/approved"), + Arguments.of(HttpMethod.GET, "/api/events"), + Arguments.of(HttpMethod.GET, "/api/musallah/config"), + Arguments.of(HttpMethod.POST, "/api/agent/enroll"), + Arguments.of(HttpMethod.GET, "/swagger-ui.html"), + Arguments.of(HttpMethod.GET, "/v3/api-docs/swagger-config")); + } + + @ParameterizedTest(name = "[{index}] {0} {1} reachable anonymously") + @MethodSource("publicPaths") + @WithAnonymousUser + @DisplayName("Public path prefixes are reachable anonymously (not 401)") + void publicPathsAreNotUnauthorized(HttpMethod method, String path) throws Exception { + assertThat(status(method, path)) + .as("%s %s should be publicly reachable (not 401)", method, path) + .isNotEqualTo(UNAUTHORIZED); + } + + // --- Protected paths: anonymous access must be 401 ------------------------------- + + static Stream protectedPaths() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/api/user/profile"), + Arguments.of(HttpMethod.GET, "/api/upload/limits"), + Arguments.of(HttpMethod.GET, "/api/admin/uploads"), + Arguments.of(HttpMethod.GET, "/api/admin/board/configs")); + } + + @ParameterizedTest(name = "[{index}] {0} {1} -> 401 when anonymous") + @MethodSource("protectedPaths") + @WithAnonymousUser + @DisplayName("Protected paths return 401 when anonymous") + void protectedPathsReturnUnauthorizedWhenAnonymous(HttpMethod method, String path) throws Exception { + assertThat(status(method, path)) + .as("%s %s should require authentication", method, path) + .isEqualTo(UNAUTHORIZED); + } + + // --- Role: USER ------------------------------------------------------------------ + + @ParameterizedTest(name = "[{index}] USER {0} {1} -> {2}") + @MethodSource("userRoleCases") + @WithMockUser(roles = "USER") + @DisplayName("USER role: own profile allowed, admin endpoints forbidden") + void userRoleMatrix(HttpMethod method, String path, boolean expectForbidden) throws Exception { + int code = status(method, path); + if (expectForbidden) { + assertThat(code).as("%s %s should be 403 for USER", method, path).isEqualTo(FORBIDDEN); + } else { + assertThat(code).as("%s %s should be allowed for USER (not 401/403)", method, path) + .isNotEqualTo(UNAUTHORIZED).isNotEqualTo(FORBIDDEN); + } + } + + static Stream userRoleCases() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/api/user/profile", false), + Arguments.of(HttpMethod.GET, "/api/admin/uploads", true), + Arguments.of(HttpMethod.GET, "/api/admin/board/configs", true)); + } + + // --- Role: ADMIN ----------------------------------------------------------------- + + @ParameterizedTest(name = "[{index}] ADMIN {0} {1} -> {2}") + @MethodSource("adminRoleCases") + @WithMockUser(roles = "ADMIN") + @DisplayName("ADMIN role: admin uploads allowed, board configs forbidden (ROOT only)") + void adminRoleMatrix(HttpMethod method, String path, boolean expectForbidden) throws Exception { + int code = status(method, path); + if (expectForbidden) { + assertThat(code).as("%s %s should be 403 for ADMIN", method, path).isEqualTo(FORBIDDEN); + } else { + assertThat(code).as("%s %s should be allowed for ADMIN (not 401/403)", method, path) + .isNotEqualTo(UNAUTHORIZED).isNotEqualTo(FORBIDDEN); + } + } + + static Stream adminRoleCases() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/api/admin/uploads", false), + Arguments.of(HttpMethod.GET, "/api/admin/board/configs", true)); + } + + // --- Role: ROOT ------------------------------------------------------------------ + + @ParameterizedTest(name = "[{index}] ROOT {0} {1} allowed") + @MethodSource("rootRoleCases") + @WithMockUser(roles = "ROOT") + @DisplayName("ROOT role: board configs allowed (not 401/403)") + void rootRoleMatrix(HttpMethod method, String path) throws Exception { + assertThat(status(method, path)) + .as("%s %s should be allowed for ROOT (not 401/403)", method, path) + .isNotEqualTo(UNAUTHORIZED).isNotEqualTo(FORBIDDEN); + } + + static Stream rootRoleCases() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/api/admin/board/configs")); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/security/services/AuditServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/security/services/AuditServiceTest.java new file mode 100644 index 0000000..27e2abc --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/security/services/AuditServiceTest.java @@ -0,0 +1,111 @@ +package com.ibrasoft.lensbridge.security.services; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuditServiceTest { + + private final AuditService service = new AuditService(); + + private ch.qos.logback.classic.Logger auditLogger; + private ListAppender appender; + + @BeforeEach + void attachAppender() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + auditLogger = ctx.getLogger("AUDIT"); + appender = new ListAppender<>(); + appender.start(); + auditLogger.addAppender(appender); + auditLogger.setLevel(Level.INFO); + } + + @AfterEach + void detachAppender() { + auditLogger.detachAppender(appender); + } + + private String lastMessage() { + List events = appender.list; + assertThat(events).isNotEmpty(); + return events.get(events.size() - 1).getFormattedMessage(); + } + + @Test + void logSecurityEventIncludesEventUserAndDetails() { + service.logSecurityEvent("CUSTOM_EVENT", "alice", "did something"); + + String msg = lastMessage(); + assertThat(msg).contains("CUSTOM_EVENT"); + assertThat(msg).contains("User: alice"); + assertThat(msg).contains("Details: did something"); + assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.INFO); + } + + @Test + void logLoginAttemptSuccessUsesLoginSuccessEvent() { + service.logLoginAttempt("bob", true, "192.168.1.1"); + + String msg = lastMessage(); + assertThat(msg).contains("LOGIN_SUCCESS"); + assertThat(msg).contains("User: bob"); + assertThat(msg).contains("IP: 192.168.1.1"); + } + + @Test + void logLoginAttemptFailureUsesLoginFailedEvent() { + service.logLoginAttempt("bob", false, "192.168.1.1"); + + String msg = lastMessage(); + assertThat(msg).contains("LOGIN_FAILED"); + assertThat(msg).doesNotContain("LOGIN_SUCCESS"); + } + + @Test + void logPasswordChangeEmitsPasswordChangedEvent() { + service.logPasswordChange("carol", "10.0.0.5"); + + String msg = lastMessage(); + assertThat(msg).contains("PASSWORD_CHANGED"); + assertThat(msg).contains("User: carol"); + assertThat(msg).contains("IP: 10.0.0.5"); + } + + @Test + void logEmailVerificationEmitsEmailVerifiedEvent() { + service.logEmailVerification("dave", "dave@example.com"); + + String msg = lastMessage(); + assertThat(msg).contains("EMAIL_VERIFIED"); + assertThat(msg).contains("User: dave"); + assertThat(msg).contains("Email: dave@example.com"); + } + + @Test + void logPasswordResetEmitsPasswordResetRequestedEvent() { + service.logPasswordReset("erin", "erin@example.com"); + + String msg = lastMessage(); + assertThat(msg).contains("PASSWORD_RESET_REQUESTED"); + assertThat(msg).contains("User: erin"); + assertThat(msg).contains("Email: erin@example.com"); + } + + @Test + void messageContainsTimestampPrefix() { + service.logSecurityEvent("E", "u", "d"); + + // Format: [yyyy-MM-dd HH:mm:ss] E - User: u - Details: d + assertThat(lastMessage()).matches("\\[\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}] E - User: u - Details: d"); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/AdminAuditServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/AdminAuditServiceTest.java new file mode 100644 index 0000000..b8bb54c --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/AdminAuditServiceTest.java @@ -0,0 +1,282 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.audit.AuditEntityType; +import com.ibrasoft.lensbridge.model.audit.AuditEvent; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.repository.audit.AuditEventRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAuditServiceTest { + + @Mock + private AuditEventRepository auditEventRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private AdminAuditService service; + + private User newAdmin() { + User u = new User(); + u.setId(UUID.randomUUID()); + u.setFirstName("Jane"); + u.setLastName("Admin"); + u.setEmail("jane@example.com"); + return u; + } + + private AuditEvent newEvent(User admin) { + return AuditEvent.builder() + .id(UUID.randomUUID()) + .action(AuditAction.APPROVE_UPLOAD) + .admin(admin) + .targetEntityType(AuditEntityType.UPLOAD) + .targetEntityId(UUID.randomUUID()) + .ipAddress("127.0.0.1") + .userAgent("JUnit") + .build(); + } + + // ---- logAuditEvent(AuditEvent) ---- + + @Test + void logAuditEventSetsTimestampAndPersists() { + User admin = newAdmin(); + AuditEvent event = newEvent(admin); + when(auditEventRepository.save(any(AuditEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + Instant before = Instant.now(); + AuditEvent saved = service.logAuditEvent(event); + + assertThat(saved).isNotNull(); + assertThat(saved.getTimestamp()).isNotNull(); + assertThat(saved.getTimestamp()).isAfterOrEqualTo(before); + verify(auditEventRepository).save(event); + } + + @Test + void logAuditEventReturnsNullWhenRepositoryThrows() { + User admin = newAdmin(); + AuditEvent event = newEvent(admin); + when(auditEventRepository.save(any(AuditEvent.class))) + .thenThrow(new RuntimeException("db down")); + + AuditEvent result = service.logAuditEvent(event); + + assertThat(result).isNull(); + } + + // ---- logAuditEvent(String, AuditAction, String, UUID, String) ---- + + @Test + void logAuditEventByEmailBuildsEventFromLookedUpAdmin() { + User admin = newAdmin(); + UUID entityId = UUID.randomUUID(); + when(userRepository.findByEmail("jane@example.com")).thenReturn(Optional.of(admin)); + when(auditEventRepository.save(any(AuditEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + AuditEvent result = service.logAuditEvent( + "jane@example.com", AuditAction.DELETE_UPLOAD, "upload", entityId, "10.0.0.1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEvent.class); + verify(auditEventRepository).save(captor.capture()); + AuditEvent built = captor.getValue(); + assertThat(built.getAdmin()).isSameAs(admin); + assertThat(built.getAction()).isEqualTo(AuditAction.DELETE_UPLOAD); + assertThat(built.getTargetEntityType()).isEqualTo(AuditEntityType.UPLOAD); + assertThat(built.getTargetEntityId()).isEqualTo(entityId); + assertThat(built.getIpAddress()).isEqualTo("10.0.0.1"); + assertThat(result).isNotNull(); + } + + @Test + void logAuditEventByEmailThrowsWhenAdminNotFound() { + when(userRepository.findByEmail("missing@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.logAuditEvent("missing@example.com", + AuditAction.APPROVE_UPLOAD, "upload", UUID.randomUUID(), "1.1.1.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing@example.com"); + verify(auditEventRepository, never()).save(any()); + } + + @Test + void logAuditEventByEmailMapsEntityTypeStrings() { + User admin = newAdmin(); + when(userRepository.findByEmail(any())).thenReturn(Optional.of(admin)); + when(auditEventRepository.save(any(AuditEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEvent.class); + + service.logAuditEvent("e", AuditAction.UPDATE_USER, "user", UUID.randomUUID(), "ip"); + service.logAuditEvent("e", AuditAction.UPDATE_USER, "poster", UUID.randomUUID(), "ip"); + service.logAuditEvent("e", AuditAction.UPDATE_USER, "calendarevent", UUID.randomUUID(), "ip"); + service.logAuditEvent("e", AuditAction.UPDATE_USER, "boardevent", UUID.randomUUID(), "ip"); + service.logAuditEvent("e", AuditAction.UPDATE_USER, "musallah board", UUID.randomUUID(), "ip"); + service.logAuditEvent("e", AuditAction.UPDATE_USER, "unknown", UUID.randomUUID(), "ip"); + service.logAuditEvent("e", AuditAction.UPDATE_USER, null, UUID.randomUUID(), "ip"); + + verify(auditEventRepository, times(7)).save(captor.capture()); + List events = captor.getAllValues(); + assertThat(events.get(0).getTargetEntityType()).isEqualTo(AuditEntityType.USER); + assertThat(events.get(1).getTargetEntityType()).isEqualTo(AuditEntityType.MUSALLAH_BOARD); + assertThat(events.get(2).getTargetEntityType()).isEqualTo(AuditEntityType.MUSALLAH_BOARD); + assertThat(events.get(3).getTargetEntityType()).isEqualTo(AuditEntityType.MUSALLAH_BOARD); + assertThat(events.get(4).getTargetEntityType()).isEqualTo(AuditEntityType.MUSALLAH_BOARD); + assertThat(events.get(5).getTargetEntityType()).isEqualTo(AuditEntityType.EVENT); + assertThat(events.get(6).getTargetEntityType()).isEqualTo(AuditEntityType.EVENT); + } + + // ---- queries ---- + + @Test + void getAllAuditEventsMapsToDtoPage() { + User admin = newAdmin(); + AuditEvent event = newEvent(admin); + Pageable pageable = PageRequest.of(0, 10); + when(auditEventRepository.findAllByOrderByTimestampDesc(pageable)) + .thenReturn(new PageImpl<>(List.of(event))); + + Page page = service.getAllAuditEvents(pageable); + + assertThat(page.getContent()).hasSize(1); + AuditEventDto dto = page.getContent().get(0); + assertThat(dto.getId()).isEqualTo(event.getId()); + assertThat(dto.getAction()).isEqualTo(AuditAction.APPROVE_UPLOAD); + assertThat(dto.getAdminId()).isEqualTo(admin.getId()); + assertThat(dto.getAdminName()).isEqualTo("Jane Admin"); + assertThat(dto.getAdminEmail()).isEqualTo("jane@example.com"); + assertThat(dto.getTargetEntityType()).isEqualTo(AuditEntityType.UPLOAD); + assertThat(dto.getTargetEntityId()).isEqualTo(event.getTargetEntityId()); + assertThat(dto.getIpAddress()).isEqualTo("127.0.0.1"); + assertThat(dto.getUserAgent()).isEqualTo("JUnit"); + } + + @Test + void getAuditEventsByAdminMapsList() { + User admin = newAdmin(); + when(auditEventRepository.findByAdminIdOrderByTimestampDesc(admin.getId())) + .thenReturn(List.of(newEvent(admin), newEvent(admin))); + + List dtos = service.getAuditEventsByAdmin(admin.getId()); + + assertThat(dtos).hasSize(2); + assertThat(dtos).allSatisfy(d -> assertThat(d.getAdminId()).isEqualTo(admin.getId())); + } + + @Test + void getAuditEventsByEntityResolvesEntityType() { + User admin = newAdmin(); + UUID entityId = UUID.randomUUID(); + when(auditEventRepository.findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc( + AuditEntityType.UPLOAD, entityId)).thenReturn(List.of(newEvent(admin))); + + List dtos = service.getAuditEventsByEntity("upload", entityId); + + assertThat(dtos).hasSize(1); + verify(auditEventRepository).findByTargetEntityTypeAndTargetEntityIdOrderByTimestampDesc( + AuditEntityType.UPLOAD, entityId); + } + + @Test + void getAuditEventsByActionMapsPage() { + User admin = newAdmin(); + Pageable pageable = PageRequest.of(0, 5); + when(auditEventRepository.findByActionOrderByTimestampDesc(AuditAction.DELETE_UPLOAD, pageable)) + .thenReturn(new PageImpl<>(List.of(newEvent(admin)))); + + Page page = service.getAuditEventsByAction(AuditAction.DELETE_UPLOAD, pageable); + + assertThat(page.getTotalElements()).isEqualTo(1); + } + + @Test + void getAuditEventsByDateRangeMapsPage() { + User admin = newAdmin(); + Instant start = Instant.now().minusSeconds(3600); + Instant end = Instant.now(); + Pageable pageable = PageRequest.of(0, 5); + when(auditEventRepository.findByTimestampBetweenOrderByTimestampDesc(start, end, pageable)) + .thenReturn(new PageImpl<>(List.of(newEvent(admin)))); + + Page page = service.getAuditEventsByDateRange(start, end, pageable); + + assertThat(page.getContent()).hasSize(1); + verify(auditEventRepository).findByTimestampBetweenOrderByTimestampDesc(start, end, pageable); + } + + @Test + void getFailedOperationsReturnsEmptyPage() { + Pageable pageable = PageRequest.of(0, 5); + + Page page = service.getFailedOperations(pageable); + + assertThat(page.getTotalElements()).isZero(); + assertThat(page.getContent()).isEmpty(); + verify(auditEventRepository, never()).findAllByOrderByTimestampDesc(any()); + } + + // ---- DTO mapping edge: null admin ---- + + @Test + void toDtoHandlesNullAdminGracefully() { + AuditEvent event = AuditEvent.builder() + .id(UUID.randomUUID()) + .action(AuditAction.SYSTEM_MAINTENANCE) + .timestamp(Instant.now()) + .admin(null) + .targetEntityType(AuditEntityType.EVENT) + .build(); + when(auditEventRepository.findAllByOrderByTimestampDesc(any())) + .thenReturn(new PageImpl<>(List.of(event))); + + AuditEventDto dto = service.getAllAuditEvents(PageRequest.of(0, 1)).getContent().get(0); + + assertThat(dto.getAdminId()).isNull(); + assertThat(dto.getAdminName()).isNull(); + assertThat(dto.getAdminEmail()).isNull(); + } + + // ---- statistics ---- + + @Test + void getOperationCountByAdminDelegatesToRepository() { + UUID adminId = UUID.randomUUID(); + when(auditEventRepository.countByAdminId(adminId)).thenReturn(7L); + + assertThat(service.getOperationCountByAdmin(adminId)).isEqualTo(7L); + } + + @Test + void getOperationCountByActionDelegatesToRepository() { + when(auditEventRepository.countByAction(AuditAction.APPROVE_UPLOAD)).thenReturn(3L); + + assertThat(service.getOperationCountByAction(AuditAction.APPROVE_UPLOAD)).isEqualTo(3L); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java new file mode 100644 index 0000000..c51adfa --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java @@ -0,0 +1,388 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.IslamicQuote; +import com.ibrasoft.lensbridge.model.board.Location; +import com.ibrasoft.lensbridge.model.board.WeekId; +import com.ibrasoft.lensbridge.model.board.WeeklyContent; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; +import com.ibrasoft.lensbridge.repository.sql.BoardEventRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.repository.sql.WeeklyContentRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BoardServiceTest { + + @Mock + private BoardConfigRepository boardConfigRepository; + @Mock + private DeviceRepository deviceRepository; + @Mock + private BoardEventRepository boardEventRepository; + @Mock + private WeeklyContentRepository weeklyContentRepository; + + @InjectMocks + private BoardService service; + + private DeviceConfig config(UUID deviceId) { + DeviceConfig c = new DeviceConfig(); + c.setId(deviceId); + c.setPosterCycleIntervalMs(5000); + return c; + } + + // ==================== Board Config ==================== + + @Test + void getBoardConfigReturnsOptional() { + UUID id = UUID.randomUUID(); + DeviceConfig c = config(id); + when(boardConfigRepository.findById(id)).thenReturn(Optional.of(c)); + + assertThat(service.getBoardConfig(id)).contains(c); + } + + @Test + void getBoardConfigOrThrowThrowsNotFound() { + UUID id = UUID.randomUUID(); + when(boardConfigRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getBoardConfigOrThrow(id)) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void saveBoardConfigAttachesDeviceAndPersists() { + UUID id = UUID.randomUUID(); + Device device = Device.builder().id(id).displayName("d").build(); + DeviceConfig c = config(id); + when(deviceRepository.findById(id)).thenReturn(Optional.of(device)); + when(boardConfigRepository.save(c)).thenReturn(c); + + DeviceConfig saved = service.saveBoardConfig(id, c); + + assertThat(saved.getDevice()).isSameAs(device); + verify(boardConfigRepository).save(c); + } + + @Test + void saveBoardConfigThrowsWhenDeviceMissing() { + UUID id = UUID.randomUUID(); + when(deviceRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.saveBoardConfig(id, config(id))) + .isInstanceOf(ApiResponseException.class); + verify(boardConfigRepository, never()).save(any()); + } + + @Test + void updateBoardConfigPatchesOnlyNonNullFields() { + UUID id = UUID.randomUUID(); + DeviceConfig existing = config(id); + existing.setEnableScrollingMessage(false); + when(boardConfigRepository.findById(id)).thenReturn(Optional.of(existing)); + when(boardConfigRepository.save(existing)).thenReturn(existing); + + UpdateBoardConfigRequest request = UpdateBoardConfigRequest.builder() + .posterCycleIntervalMs(9000) + .enableScrollingMessage(true) + .build(); + + DeviceConfig saved = service.updateBoardConfig(id, request); + + assertThat(saved.getPosterCycleIntervalMs()).isEqualTo(9000); + assertThat(saved.isEnableScrollingMessage()).isTrue(); + assertThat(saved.getLocation()).isNull(); // untouched + } + + @Test + void updateBoardConfigThrowsWhenConfigMissing() { + UUID id = UUID.randomUUID(); + when(boardConfigRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateBoardConfig(id, UpdateBoardConfigRequest.builder().build())) + .isInstanceOf(ApiResponseException.class); + } + + @Test + void getAllBoardConfigsDelegates() { + List all = List.of(config(UUID.randomUUID())); + when(boardConfigRepository.findAll()).thenReturn(all); + + assertThat(service.getAllBoardConfigs()).isEqualTo(all); + } + + // ==================== Weekly Content ==================== + + @Test + void getWeeklyContentDelegatesByYearAndWeek() { + WeeklyContent wc = WeeklyContent.builder().year(2026).weekNumber(20).build(); + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)).thenReturn(Optional.of(wc)); + + assertThat(service.getWeeklyContent(2026, 20)).contains(wc); + } + + @Test + void getWeeklyContentOrThrowThrowsWhenMissing() { + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getWeeklyContentOrThrow(2026, 20)) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void getCurrentWeeklyContentResolvesViaWeekIdFromToday() { + WeekId current = WeekId.fromDate(LocalDate.now()); + WeeklyContent wc = WeeklyContent.builder() + .year(current.getYear()).weekNumber(current.getWeekNumber()).build(); + when(weeklyContentRepository.findByYearAndWeekNumber( + current.getYear(), current.getWeekNumber())).thenReturn(Optional.of(wc)); + + assertThat(service.getCurrentWeeklyContent()).contains(wc); + } + + @Test + void getWeeklyContentByYearDelegates() { + List list = List.of(WeeklyContent.builder().year(2026).weekNumber(1).build()); + when(weeklyContentRepository.findByYear(2026)).thenReturn(list); + + assertThat(service.getWeeklyContentByYear(2026)).isEqualTo(list); + } + + @Test + void saveWeeklyContentCreatesNewWhenAbsentAndMapsQuotes() { + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)).thenReturn(Optional.empty()); + when(weeklyContentRepository.save(any(WeeklyContent.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + WeeklyContentRequest request = WeeklyContentRequest.builder() + .quotes(List.of(new WeeklyContentRequest.QuoteEntry( + IslamicQuote.Kind.VERSE, "ar", "tr", "en", "Quran 1:1"))) + .jummahPrayers(List.of(new WeeklyContentRequest.JummahSlot("13:30", "Imam", "Hall"))) + .build(); + + WeeklyContent saved = service.saveWeeklyContent(2026, 20, request); + + assertThat(saved.getYear()).isEqualTo(2026); + assertThat(saved.getWeekNumber()).isEqualTo(20); + assertThat(saved.getQuotes()).hasSize(1); + assertThat(saved.getQuotes().get(0).getKind()).isEqualTo(IslamicQuote.Kind.VERSE); + assertThat(saved.getJummahPrayers()).hasSize(1); + assertThat(saved.getJummahPrayers().get(0).getKhatib()).isEqualTo("Imam"); + assertThat(saved.getJummahPrayers().get(0).getPrayerTime().toString()).isEqualTo("13:30"); + } + + @Test + void saveWeeklyContentReusesExistingAndClearsQuotesBeforeRepopulating() { + WeeklyContent existing = WeeklyContent.builder().year(2026).weekNumber(20).build(); + existing.getQuotes().add(IslamicQuote.builder().arabic("stale").build()); + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)) + .thenReturn(Optional.of(existing)); + when(weeklyContentRepository.save(any(WeeklyContent.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + WeeklyContentRequest request = WeeklyContentRequest.builder() + .quotes(List.of(new WeeklyContentRequest.QuoteEntry( + IslamicQuote.Kind.HADITH, "a", "t", "e", "ref"))) + .build(); + + WeeklyContent saved = service.saveWeeklyContent(2026, 20, request); + + assertThat(saved).isSameAs(existing); + assertThat(saved.getQuotes()).hasSize(1); + assertThat(saved.getQuotes().get(0).getArabic()).isEqualTo("a"); + } + + @Test + void saveWeeklyContentLeavesCollectionsUntouchedWhenRequestFieldsNull() { + WeeklyContent existing = WeeklyContent.builder().year(2026).weekNumber(20).build(); + existing.getQuotes().add(IslamicQuote.builder().arabic("keep").build()); + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)) + .thenReturn(Optional.of(existing)); + when(weeklyContentRepository.save(any(WeeklyContent.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + WeeklyContent saved = service.saveWeeklyContent(2026, 20, + WeeklyContentRequest.builder().build()); + + assertThat(saved.getQuotes()).hasSize(1); + assertThat(saved.getQuotes().get(0).getArabic()).isEqualTo("keep"); + } + + @Test + void deleteWeeklyContentThrowsWhenMissing() { + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteWeeklyContent(2026, 20)) + .isInstanceOf(ApiResponseException.class); + verify(weeklyContentRepository, never()).delete(any()); + } + + @Test + void deleteWeeklyContentDeletesWhenPresent() { + WeeklyContent wc = WeeklyContent.builder().year(2026).weekNumber(20).build(); + when(weeklyContentRepository.findByYearAndWeekNumber(2026, 20)) + .thenReturn(Optional.of(wc)); + + service.deleteWeeklyContent(2026, 20); + + verify(weeklyContentRepository).delete(wc); + } + + // ==================== Events ==================== + + @Test + void getAllEventsDelegatesSortedByStartTime() { + List events = List.of(BoardEvent.builder().name("e").build()); + when(boardEventRepository.findAllByOrderByStartTimeAsc()).thenReturn(events); + + assertThat(service.getAllEvents()).isEqualTo(events); + } + + @Test + void getEventByIdThrowsWhenMissing() { + UUID id = UUID.randomUUID(); + when(boardEventRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEventById(id)) + .isInstanceOf(ApiResponseException.class); + } + + @Test + void getEventsForAudienceFiltersByAudienceOrBoth() { + List events = List.of(BoardEvent.builder().name("e").build()); + when(boardEventRepository.findByAudienceOrBoth(Audience.SISTERS)).thenReturn(events); + + assertThat(service.getEventsForAudience(Audience.SISTERS)).isEqualTo(events); + } + + @Test + void getUpcomingEventsForAudienceDelegates() { + List events = List.of(BoardEvent.builder().name("e").build()); + when(boardEventRepository.findUpcomingByAudienceOrBoth(eq(Audience.BROTHERS), any(Instant.class))) + .thenReturn(events); + + assertThat(service.getUpcomingEventsForAudience(Audience.BROTHERS)).isEqualTo(events); + } + + @Test + void getEventsForAudienceInRangeDelegatesWithRange() { + Instant start = Instant.now(); + Instant end = start.plusSeconds(3600); + List events = List.of(BoardEvent.builder().name("e").build()); + when(boardEventRepository.findOverlappingForAudienceOrBoth(Audience.BOTH, start, end)) + .thenReturn(events); + + assertThat(service.getEventsForAudienceInRange(Audience.BOTH, start, end)).isEqualTo(events); + } + + // @Test + // void createEventBuildsAndPersists() { + // when(boardEventRepository.save(any(BoardEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + // Instant start = Instant.now(); + // CreateCalendarEventRequest request = CreateCalendarEventRequest.builder() + // .name("Halaqa") + // .description("desc") + // .location("MSA Room") + // .startTime(start) + // .endTime(start.plusSeconds(3600)) + // .allDay(false) + // .audience(Audience.BOTH) + // .build(); + + // BoardEvent created = service.createEvent(request); + + // assertThat(created.getName()).isEqualTo("Halaqa"); + // assertThat(created.getLocation()).isEqualTo("MSA Room"); + // assertThat(created.getAudience()).isEqualTo(Audience.BOTH); + // } + + @Test + void updateEventPatchesOnlyNonNullFields() { + UUID id = UUID.randomUUID(); + BoardEvent existing = BoardEvent.builder() + .id(id).name("Old").description("d").audience(Audience.BOTH).build(); + when(boardEventRepository.findById(id)).thenReturn(Optional.of(existing)); + when(boardEventRepository.save(any(BoardEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + UpdateCalendarEventRequest request = UpdateCalendarEventRequest.builder() + .name("New").build(); + + BoardEvent updated = service.updateEvent(id, request); + + assertThat(updated.getName()).isEqualTo("New"); + assertThat(updated.getDescription()).isEqualTo("d"); // unchanged + assertThat(updated.getAudience()).isEqualTo(Audience.BOTH); // unchanged + } + + @Test + void deleteEventThrowsWhenMissing() { + UUID id = UUID.randomUUID(); + when(boardEventRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteEvent(id)) + .isInstanceOf(ApiResponseException.class); + verify(boardEventRepository, never()).delete(any()); + } + + @Test + void deleteEventDeletesWhenPresent() { + UUID id = UUID.randomUUID(); + BoardEvent existing = BoardEvent.builder().id(id).name("e").build(); + when(boardEventRepository.findById(id)).thenReturn(Optional.of(existing)); + + service.deleteEvent(id); + + verify(boardEventRepository).delete(existing); + } + + @Test + void updateBoardConfigCapturesPatchedLocation() { + UUID id = UUID.randomUUID(); + DeviceConfig existing = config(id); + when(boardConfigRepository.findById(id)).thenReturn(Optional.of(existing)); + when(boardConfigRepository.save(any(DeviceConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + Location loc = Location.builder().city("Toronto").timezone("America/Toronto").build(); + service.updateBoardConfig(id, UpdateBoardConfigRequest.builder().location(loc).build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeviceConfig.class); + verify(boardConfigRepository).save(captor.capture()); + assertThat(captor.getValue().getLocation()).isSameAs(loc); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java new file mode 100644 index 0000000..8e8d631 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java @@ -0,0 +1,334 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Poster; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.repository.sql.PosterRepository; +import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PosterServiceTest { + + @Mock + private PosterRepository posterRepository; + + @Mock + private R2StorageService r2StorageService; + + @Mock + private PosterFrameTransformer posterFrameTransformer; + + @InjectMocks + private PosterService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "publicUrl", "https://cdn.example.com"); + } + + private Poster poster(String title) { + return Poster.builder() + .id(UUID.randomUUID()) + .title(title) + .image("https://cdn.example.com/poster-old.png") + .duration(10) + .startTime(Instant.now()) + .endTime(Instant.now().plusSeconds(3600)) + .audience(Audience.BOTH) + .build(); + } + + private MockMultipartFile imageFile() { + return new MockMultipartFile("file", "pic.png", "image/png", new byte[]{1, 2, 3}); + } + + // ==================== Read paths ==================== + + @Test + void getAllPostersDelegatesToRepositorySortedByStartTimeDesc() { + List expected = List.of(poster("a"), poster("b")); + when(posterRepository.findAllByOrderByStartTimeDesc()).thenReturn(expected); + + assertThat(service.getAllPosters()).isEqualTo(expected); + } + + @Test + void getPosterByIdReturnsPoster() { + Poster p = poster("a"); + when(posterRepository.findById(p.getId())).thenReturn(Optional.of(p)); + + assertThat(service.getPosterById(p.getId())).isSameAs(p); + } + + @Test + void getPosterByIdThrowsNotFoundWhenMissing() { + UUID id = UUID.randomUUID(); + when(posterRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getPosterById(id)) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void getPostersForAudienceFiltersByAudienceOrBoth() { + List expected = List.of(poster("a")); + when(posterRepository.findByAudienceOrBoth(Audience.SISTERS)).thenReturn(expected); + + assertThat(service.getPostersForAudience(Audience.SISTERS)).isEqualTo(expected); + } + + @Test + void getActivePosterFramesForAudienceQueriesActiveForAudience() { + List expected = List.of(poster("a")); + when(posterRepository.findActivePostersForAudienceAt(any(Instant.class), any(Audience.class))) + .thenReturn(expected); + + assertThat(service.getActivePosterFramesForAudience(Audience.BROTHERS)).isEqualTo(expected); + } + + @Test + void getActivePostersQueriesActiveAtNow() { + List expected = List.of(poster("a")); + when(posterRepository.findActivePostersAt(any(Instant.class))).thenReturn(expected); + + assertThat(service.getActivePosters()).isEqualTo(expected); + } + + @Test + void getActivePosterFrameDefinitionsMapsThroughTransformer() { + Poster p = poster("a"); + FrameDefinition frame = FrameDefinition.builder().frameType(FrameType.POSTER).build(); + when(posterRepository.findActivePostersForAudienceAt(any(Instant.class), any(Audience.class))) + .thenReturn(List.of(p)); + when(posterFrameTransformer.transform(p, null)).thenReturn(frame); + + List result = service.getActivePosterFrameDefinitions(Audience.BOTH); + + assertThat(result).containsExactly(frame); + } + + // ==================== createPoster ==================== + + @Test + void createPosterUploadsImageAndPersists() throws IOException { + CreatePosterRequest request = CreatePosterRequest.builder() + .title("New") + .duration(15) + .startTime(Instant.now()) + .endTime(Instant.now().plusSeconds(7200)) + .audience(Audience.BROTHERS) + .build(); + when(r2StorageService.uploadImage(anyString(), any(MultipartFile.class))) + .thenReturn("poster-key.png"); + when(posterRepository.save(any(Poster.class))).thenAnswer(inv -> inv.getArgument(0)); + + Poster created = service.createPoster(request, imageFile()); + + assertThat(created.getTitle()).isEqualTo("New"); + assertThat(created.getImage()).isEqualTo("https://cdn.example.com/poster-key.png"); + assertThat(created.getDuration()).isEqualTo(15); + assertThat(created.getAudience()).isEqualTo(Audience.BROTHERS); + } + + @Test + void createPosterRejectsInvalidDateRange() { + Instant start = Instant.now(); + CreatePosterRequest request = CreatePosterRequest.builder() + .title("X") + .duration(5) + .startTime(start) + .endTime(start) // not after start + .audience(Audience.BOTH) + .build(); + + assertThatThrownBy(() -> service.createPoster(request, imageFile())) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(posterRepository, never()).save(any()); + } + + @Test + void createPosterRejectsMissingImage() { + CreatePosterRequest request = CreatePosterRequest.builder() + .title("X") + .duration(5) + .startTime(Instant.now()) + .endTime(Instant.now().plusSeconds(60)) + .audience(Audience.BOTH) + .build(); + MultipartFile empty = new MockMultipartFile("file", new byte[0]); + + assertThatThrownBy(() -> service.createPoster(request, empty)) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void createPosterRejectsNonImageContentType() { + CreatePosterRequest request = CreatePosterRequest.builder() + .title("X") + .duration(5) + .startTime(Instant.now()) + .endTime(Instant.now().plusSeconds(60)) + .audience(Audience.BOTH) + .build(); + MultipartFile pdf = new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[]{1}); + + assertThatThrownBy(() -> service.createPoster(request, pdf)) + .isInstanceOf(ApiResponseException.class); + } + + @Test + void createPosterWrapsUploadIoExceptionAsServerError() throws IOException { + CreatePosterRequest request = CreatePosterRequest.builder() + .title("X") + .duration(5) + .startTime(Instant.now()) + .endTime(Instant.now().plusSeconds(60)) + .audience(Audience.BOTH) + .build(); + when(r2StorageService.uploadImage(anyString(), any(MultipartFile.class))) + .thenThrow(new IOException("boom")); + + assertThatThrownBy(() -> service.createPoster(request, imageFile())) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)); + } + + // ==================== updatePoster ==================== + + @Test + void updatePosterAppliesOnlyNonNullFields() { + Poster existing = poster("Old"); + when(posterRepository.findById(existing.getId())).thenReturn(Optional.of(existing)); + when(posterRepository.save(any(Poster.class))).thenAnswer(inv -> inv.getArgument(0)); + + UpdatePosterRequest request = UpdatePosterRequest.builder() + .title("Updated") + .build(); + + Poster updated = service.updatePoster(existing.getId(), request); + + assertThat(updated.getTitle()).isEqualTo("Updated"); + assertThat(updated.getDuration()).isEqualTo(10); // unchanged + assertThat(updated.getAudience()).isEqualTo(Audience.BOTH); // unchanged + } + + @Test + void updatePosterThrowsWhenNotFound() { + UUID id = UUID.randomUUID(); + when(posterRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updatePoster(id, UpdatePosterRequest.builder().build())) + .isInstanceOf(ApiResponseException.class); + } + + @Test + void updatePosterValidatesResultingDateRange() { + Poster existing = poster("Old"); + Instant start = existing.getStartTime(); + when(posterRepository.findById(existing.getId())).thenReturn(Optional.of(existing)); + + UpdatePosterRequest request = UpdatePosterRequest.builder() + .endTime(start.minusSeconds(10)) // end before start + .build(); + + assertThatThrownBy(() -> service.updatePoster(existing.getId(), request)) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(posterRepository, never()).save(any()); + } + + // ==================== updatePosterImage ==================== + + @Test + void updatePosterImageDeletesOldAndUploadsNew() throws IOException { + Poster existing = poster("Old"); + when(posterRepository.findById(existing.getId())).thenReturn(Optional.of(existing)); + when(r2StorageService.uploadImage(anyString(), any(MultipartFile.class))) + .thenReturn("new-key.png"); + when(posterRepository.save(any(Poster.class))).thenAnswer(inv -> inv.getArgument(0)); + + Poster updated = service.updatePosterImage(existing.getId(), imageFile()); + + verify(r2StorageService).deleteObject("https://cdn.example.com/poster-old.png"); + assertThat(updated.getImage()).isEqualTo("https://cdn.example.com/new-key.png"); + } + + @Test + void updatePosterImageThrowsWhenNotFound() { + UUID id = UUID.randomUUID(); + when(posterRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updatePosterImage(id, imageFile())) + .isInstanceOf(ApiResponseException.class); + } + + // ==================== deletePoster ==================== + + @Test + void deletePosterRemovesImageAndEntity() { + Poster existing = poster("Old"); + when(posterRepository.findById(existing.getId())).thenReturn(Optional.of(existing)); + + service.deletePoster(existing.getId()); + + verify(r2StorageService).deleteObject("https://cdn.example.com/poster-old.png"); + verify(posterRepository).delete(existing); + } + + @Test + void deletePosterStillDeletesEntityWhenImageRemovalFails() { + Poster existing = poster("Old"); + when(posterRepository.findById(existing.getId())).thenReturn(Optional.of(existing)); + org.mockito.Mockito.doThrow(new RuntimeException("r2 down")) + .when(r2StorageService).deleteObject(anyString()); + + service.deletePoster(existing.getId()); + + verify(posterRepository).delete(existing); + } + + @Test + void deletePosterThrowsWhenNotFound() { + UUID id = UUID.randomUUID(); + when(posterRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deletePoster(id)) + .isInstanceOf(ApiResponseException.class); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/RefreshTokenServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/RefreshTokenServiceTest.java new file mode 100644 index 0000000..a3461ca --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/RefreshTokenServiceTest.java @@ -0,0 +1,242 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.exception.RefreshTokenException; +import com.ibrasoft.lensbridge.model.auth.RefreshToken; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.repository.auth.RefreshTokenRepository; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceTest { + + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock + private UserRepository userRepository; + + @InjectMocks + private RefreshTokenService service; + + private UUID userId; + private User user; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "refreshTokenDurationMs", 604_800_000L); + ReflectionTestUtils.setField(service, "maxRefreshTokensPerUser", 5); + userId = UUID.randomUUID(); + user = new User("A", "B", "1", "a@b.ca", "p"); + user.setId(userId); + lenient().when(refreshTokenRepository.save(any(RefreshToken.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + private RefreshToken token(Instant created, boolean revoked) { + return RefreshToken.builder() + .tokenHash(UUID.randomUUID().toString()) + .user(user) + .createdDate(created) + .expiryDate(Instant.now().plusSeconds(3600)) + .lastUsedDate(created) + .revoked(revoked) + .build(); + } + + @Test + void createRefreshTokenSetsExpiryFromDurationProperty() { + when(refreshTokenRepository.findByUser_Id(userId)).thenReturn(List.of()); + when(refreshTokenRepository.countByUser_IdAndRevokedFalse(userId)).thenReturn(0L); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + Instant before = Instant.now(); + RefreshToken created = service.createRefreshToken(userId, "device", "1.2.3.4"); + + long ttlSeconds = created.getExpiryDate().getEpochSecond() - before.getEpochSecond(); + assertThat(ttlSeconds).isCloseTo(604_800L, org.assertj.core.data.Offset.offset(5L)); + assertThat(created.isRevoked()).isFalse(); + assertThat(created.getTokenHash()).isNotBlank(); + } + + @Test + void createRefreshTokenRevokesOldestWhenAtMaxLimit() { + RefreshToken oldest = token(Instant.now().minusSeconds(1000), false); + RefreshToken newer = token(Instant.now().minusSeconds(10), false); + when(refreshTokenRepository.findByUser_Id(userId)).thenReturn(List.of()); + when(refreshTokenRepository.countByUser_IdAndRevokedFalse(userId)).thenReturn(5L); + when(refreshTokenRepository.findByUser_IdAndRevokedFalse(userId)).thenReturn(List.of(newer, oldest)); + when(refreshTokenRepository.findByTokenHash(oldest.getTokenHash())).thenReturn(Optional.of(oldest)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + service.createRefreshToken(userId, "device", "1.2.3.4"); + + assertThat(oldest.isRevoked()).isTrue(); + assertThat(newer.isRevoked()).isFalse(); + } + + @Test + void createRefreshTokenDoesNotRevokeWhenUnderLimit() { + when(refreshTokenRepository.findByUser_Id(userId)).thenReturn(List.of()); + when(refreshTokenRepository.countByUser_IdAndRevokedFalse(userId)).thenReturn(4L); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + service.createRefreshToken(userId, "device", "1.2.3.4"); + + // findByUser_IdAndRevokedFalse only called for revocation path; under limit -> not called + verify(refreshTokenRepository, never()).findByUser_IdAndRevokedFalse(userId); + } + + @Test + void createRefreshTokenThrowsWhenUserNotFound() { + when(refreshTokenRepository.findByUser_Id(userId)).thenReturn(List.of()); + when(refreshTokenRepository.countByUser_IdAndRevokedFalse(userId)).thenReturn(0L); + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.createRefreshToken(userId, "device", "ip")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("User not found"); + } + + @Test + void verifyExpirationRejectsRevokedTokenAndDeletesIt() { + RefreshToken revoked = token(Instant.now(), true); + + assertThatThrownBy(() -> service.verifyExpiration(revoked)) + .isInstanceOf(RefreshTokenException.class) + .hasMessageContaining("revoked"); + verify(refreshTokenRepository).delete(revoked); + } + + @Test + void verifyExpirationRejectsExpiredTokenAndDeletesIt() { + RefreshToken expired = token(Instant.now(), false); + expired.setExpiryDate(Instant.now().minusSeconds(60)); + + assertThatThrownBy(() -> service.verifyExpiration(expired)) + .isInstanceOf(RefreshTokenException.class) + .hasMessageContaining("expired"); + verify(refreshTokenRepository).delete(expired); + } + + @Test + void verifyExpirationUpdatesLastUsedDateForValidToken() { + RefreshToken valid = token(Instant.now().minusSeconds(500), false); + Instant oldLastUsed = valid.getLastUsedDate(); + + RefreshToken result = service.verifyExpiration(valid); + + assertThat(result.getLastUsedDate()).isAfter(oldLastUsed); + verify(refreshTokenRepository).save(valid); + } + + @Test + void revokeRefreshTokenMarksTokenRevoked() { + RefreshToken t = token(Instant.now(), false); + when(refreshTokenRepository.findByTokenHash(t.getTokenHash())).thenReturn(Optional.of(t)); + + service.revokeRefreshToken(t.getTokenHash()); + + assertThat(t.isRevoked()).isTrue(); + verify(refreshTokenRepository).save(t); + } + + @Test + void revokeRefreshTokenIsNoOpForUnknownToken() { + when(refreshTokenRepository.findByTokenHash("nope")).thenReturn(Optional.empty()); + + service.revokeRefreshToken("nope"); + + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void revokeAllUserTokensRevokesEveryActiveToken() { + RefreshToken a = token(Instant.now(), false); + RefreshToken b = token(Instant.now(), false); + when(refreshTokenRepository.findByUser_IdAndRevokedFalse(userId)).thenReturn(List.of(a, b)); + + service.revokeAllUserTokens(userId); + + assertThat(a.isRevoked()).isTrue(); + assertThat(b.isRevoked()).isTrue(); + verify(refreshTokenRepository).saveAll(anyList()); + } + + @Test + void deleteExpiredTokensByUserDeletesExpiredAndRevoked() { + RefreshToken expired = token(Instant.now(), false); + expired.setExpiryDate(Instant.now().minusSeconds(60)); + RefreshToken revoked = token(Instant.now(), true); + RefreshToken active = token(Instant.now(), false); + when(refreshTokenRepository.findByUser_Id(userId)).thenReturn(List.of(expired, revoked, active)); + + service.deleteExpiredTokensByUser(userId); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(refreshTokenRepository).deleteAll(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(expired, revoked); + } + + @Test + void deleteExpiredTokensByUserSkipsDeleteWhenNothingExpired() { + RefreshToken active = token(Instant.now(), false); + when(refreshTokenRepository.findByUser_Id(userId)).thenReturn(List.of(active)); + + service.deleteExpiredTokensByUser(userId); + + verify(refreshTokenRepository, never()).deleteAll(anyList()); + } + + @Test + void cleanupExpiredTokensDeletesByExpiryAndOldRevoked() { + RefreshToken oldRevoked = token(Instant.now().minusSeconds(99999), true); + when(refreshTokenRepository.findByRevokedTrueAndCreatedDateBefore(any())) + .thenReturn(List.of(oldRevoked)); + + service.cleanupExpiredTokens(); + + verify(refreshTokenRepository).deleteByExpiryDateBefore(any()); + verify(refreshTokenRepository).deleteAll(List.of(oldRevoked)); + } + + @Test + void getActiveTokensForUserDelegatesToRepository() { + RefreshToken a = token(Instant.now(), false); + when(refreshTokenRepository.findByUser_IdAndRevokedFalse(userId)).thenReturn(List.of(a)); + + assertThat(service.getActiveTokensForUser(userId)).containsExactly(a); + } + + @Test + void invalidateAllRefreshTokensForUserRevokesAndSaves() { + RefreshToken a = token(Instant.now(), false); + when(refreshTokenRepository.findByUser_IdAndRevokedFalse(userId)).thenReturn(List.of(a)); + + service.invalidateAllRefreshTokensForUser(userId); + + assertThat(a.isRevoked()).isTrue(); + verify(refreshTokenRepository, times(1)).saveAll(anyList()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java new file mode 100644 index 0000000..2aa43d9 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java @@ -0,0 +1,335 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.security.services.EmailService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; + @Mock + private EmailService emailService; + @Mock + private VerificationTokenService verificationTokenService; + @Mock + private RefreshTokenService refreshTokenService; + + @InjectMocks + private UserService userService; + + @BeforeEach + void echoSave() { + lenient().when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + private SignupRequest signup() { + SignupRequest req = new SignupRequest(); + req.setFirstName("Jane"); + req.setLastName("Doe"); + req.setStudentNumber("1000001"); + req.setEmail("jane@mail.utoronto.ca"); + req.setPassword("password1"); + return req; + } + + @Test + void createUserEncodesPasswordAndAssignsUserRole() { + SignupRequest req = signup(); + when(passwordEncoder.encode("password1")).thenReturn("ENC"); + + User created = userService.createUser(req, false); + + assertThat(created.getPassword()).isEqualTo("ENC"); + assertThat(created.getRoles()).containsExactly(Role.USER); + assertThat(created.getEmail()).isEqualTo("jane@mail.utoronto.ca"); + } + + @Test + void createUserWithNullPasswordStoresNullWithoutEncoding() { + SignupRequest req = signup(); + req.setPassword(null); + + User created = userService.createUser(req, false); + + assertThat(created.getPassword()).isNull(); + verify(passwordEncoder, never()).encode(any()); + } + + @Test + void createUserSendsVerificationEmailWhenRequested() { + SignupRequest req = signup(); + when(passwordEncoder.encode(anyString())).thenReturn("ENC"); + when(verificationTokenService.generateEmailVerificationToken(any(User.class))).thenReturn("tok123"); + + userService.createUser(req, true); + + verify(verificationTokenService).generateEmailVerificationToken(any(User.class)); + verify(emailService).sendVerificationEmail(eqEmail(), any(), org.mockito.ArgumentMatchers.contains("token=tok123")); + } + + private static String eqEmail() { + return org.mockito.ArgumentMatchers.eq("jane@mail.utoronto.ca"); + } + + @Test + void createUserRejectsDuplicateEmail() { + SignupRequest req = signup(); + when(userRepository.existsByEmail("jane@mail.utoronto.ca")).thenReturn(true); + + assertThatThrownBy(() -> userService.createUser(req, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already exists"); + verify(userRepository, never()).save(any()); + } + + @Test + void createUserRejectsDuplicateStudentNumber() { + SignupRequest req = signup(); + when(userRepository.existsByEmail(anyString())).thenReturn(false); + when(userRepository.existsByStudentNumber("1000001")).thenReturn(true); + + assertThatThrownBy(() -> userService.createUser(req, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void verifyDirectlySetsVerifiedAt() { + UUID id = UUID.randomUUID(); + User user = new User("A", "B", "1", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + User result = userService.verifyDirectly(id); + + assertThat(result.isVerified()).isTrue(); + assertThat(result.getVerifiedAt()).isNotNull(); + } + + @Test + void verifyDirectlyRejectsAlreadyVerifiedUser() { + UUID id = UUID.randomUUID(); + User user = new User("A", "B", "1", "a@b.ca", "p"); + user.setVerifiedAt(java.time.Instant.now()); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.verifyDirectly(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already verified"); + } + + @Test + void verifyDirectlyRejectsUnknownUser() { + UUID id = UUID.randomUUID(); + when(userRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.verifyDirectly(id)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not found"); + } + + @Test + void addRoleRejectsDuplicateRole() { + UUID id = UUID.randomUUID(); + User user = new User("A", "B", "1", "a@b.ca", "p"); + user.addRole(Role.ADMIN); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.addRole(id, Role.ADMIN)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already has role"); + } + + @Test + void addRoleAddsNewRole() { + UUID id = UUID.randomUUID(); + User user = new User("A", "B", "1", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + User result = userService.addRole(id, Role.ADMIN); + + assertThat(result.hasRole(Role.ADMIN)).isTrue(); + } + + @Test + void removeRoleRejectsMissingRole() { + UUID id = UUID.randomUUID(); + User user = new User("A", "B", "1", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.removeRole(id, Role.ADMIN)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not have role"); + } + + @Test + void removeRoleRemovesExistingRole() { + UUID id = UUID.randomUUID(); + User user = new User("A", "B", "1", "a@b.ca", "p"); + user.addRole(Role.ADMIN); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + + User result = userService.removeRole(id, Role.ADMIN); + + assertThat(result.hasRole(Role.ADMIN)).isFalse(); + } + + @Test + void requestPasswordResetSendsEmailWhenUserExists() { + User user = new User("A", "B", "1", "a@b.ca", "p"); + when(userRepository.findByEmail("a@b.ca")).thenReturn(Optional.of(user)); + when(verificationTokenService.generatePasswordResetToken(user)).thenReturn("rtok"); + + userService.requestPasswordReset("a@b.ca"); + + verify(emailService).sendPasswordResetEmail(org.mockito.ArgumentMatchers.eq("a@b.ca"), + any(), org.mockito.ArgumentMatchers.contains("token=rtok")); + } + + @Test + void requestPasswordResetIsNoOpWhenUserMissing() { + when(userRepository.findByEmail("missing@b.ca")).thenReturn(Optional.empty()); + + userService.requestPasswordReset("missing@b.ca"); + + verifyNoInteractions(emailService); + verifyNoInteractions(verificationTokenService); + } + + @Test + void resetPasswordEncodesNewPasswordAndRevokesTokens() { + User user = new User("A", "B", "1", "a@b.ca", "old"); + UUID userId = UUID.randomUUID(); + user.setId(userId); + VerificationToken vt = VerificationToken.builder().user(user).build(); + when(verificationTokenService.consumePasswordReset("ptok")).thenReturn(vt); + when(passwordEncoder.encode("newpass")).thenReturn("ENC"); + + User result = userService.resetPassword("ptok", "newpass"); + + assertThat(result.getPassword()).isEqualTo("ENC"); + verify(refreshTokenService).revokeAllUserTokens(userId); + } + + @Test + void changePasswordRejectsWrongCurrentPassword() { + User user = new User("A", "B", "1", "a@b.ca", "hash"); + ChangePasswordRequest req = new ChangePasswordRequest(); + req.setCurrentPassword("wrong"); + req.setNewPassword("brandnew"); + when(passwordEncoder.matches("wrong", "hash")).thenReturn(false); + + assertThatThrownBy(() -> userService.changePassword(user, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Current password is incorrect"); + } + + @Test + void changePasswordRejectsShortNewPassword() { + User user = new User("A", "B", "1", "a@b.ca", "hash"); + ChangePasswordRequest req = new ChangePasswordRequest(); + req.setCurrentPassword("right"); + req.setNewPassword("12345"); + when(passwordEncoder.matches("right", "hash")).thenReturn(true); + + assertThatThrownBy(() -> userService.changePassword(user, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 6 characters"); + } + + @Test + void changePasswordUpdatesHashOnSuccess() { + User user = new User("A", "B", "1", "a@b.ca", "hash"); + ChangePasswordRequest req = new ChangePasswordRequest(); + req.setCurrentPassword("right"); + req.setNewPassword("longenough"); + when(passwordEncoder.matches("right", "hash")).thenReturn(true); + when(passwordEncoder.encode("longenough")).thenReturn("ENC"); + + User result = userService.changePassword(user, req); + + assertThat(result.getPassword()).isEqualTo("ENC"); + } + + @Test + void updateProfileTrimsAndUpdatesFields() { + UUID id = UUID.randomUUID(); + User user = new User("Old", "Name", "1000001", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + UpdateProfileRequest req = new UpdateProfileRequest(); + req.setFirstName(" New "); + req.setLastName(" Last "); + + User result = userService.updateProfile(id, req); + + assertThat(result.getFirstName()).isEqualTo("New"); + assertThat(result.getLastName()).isEqualTo("Last"); + } + + @Test + void updateProfileIgnoresBlankFields() { + UUID id = UUID.randomUUID(); + User user = new User("Old", "Name", "1000001", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + UpdateProfileRequest req = new UpdateProfileRequest(); + req.setFirstName(" "); + + User result = userService.updateProfile(id, req); + + assertThat(result.getFirstName()).isEqualTo("Old"); + } + + @Test + void updateProfileRejectsTakenStudentNumber() { + UUID id = UUID.randomUUID(); + User user = new User("Old", "Name", "1000001", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userRepository.existsByStudentNumber("9999999")).thenReturn(true); + UpdateProfileRequest req = new UpdateProfileRequest(); + req.setStudentNumber("9999999"); + + assertThatThrownBy(() -> userService.updateProfile(id, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already taken"); + } + + @Test + void updateProfileAllowsSameStudentNumberWithoutUniquenessCheck() { + UUID id = UUID.randomUUID(); + User user = new User("Old", "Name", "1000001", "a@b.ca", "p"); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + UpdateProfileRequest req = new UpdateProfileRequest(); + req.setStudentNumber("1000001"); + + User result = userService.updateProfile(id, req); + + assertThat(result.getStudentNumber()).isEqualTo("1000001"); + verify(userRepository, never()).existsByStudentNumber(anyString()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/VerificationTokenServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/VerificationTokenServiceTest.java new file mode 100644 index 0000000..200f91e --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/VerificationTokenServiceTest.java @@ -0,0 +1,186 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.config.AuthTokenProperties; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.model.auth.VerificationToken; +import com.ibrasoft.lensbridge.model.auth.VerificationToken.TokenType; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.repository.auth.VerificationTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VerificationTokenServiceTest { + + @Mock + private VerificationTokenRepository tokenRepository; + @Mock + private UserRepository userRepository; + @Mock + private AuthTokenProperties authTokenProperties; + + @InjectMocks + private VerificationTokenService service; + + private User user; + + @BeforeEach + void setUp() { + user = new User("A", "B", "1", "a@b.ca", "p"); + lenient().when(tokenRepository.save(any(VerificationToken.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(userRepository.save(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void generateEmailVerificationTokenPersistsHashedTokenWithTtl() { + when(authTokenProperties.getVerificationExpirationMs()).thenReturn(3_600_000L); + + Instant before = Instant.now(); + String plaintext = service.generateEmailVerificationToken(user); + + assertThat(plaintext).hasSize(64); // 32 random bytes -> 64 hex chars + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + VerificationToken saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(TokenType.EMAIL_VERIFICATION); + assertThat(saved.getUser()).isSameAs(user); + assertThat(saved.getTokenHash()).isNotEqualTo(plaintext); + long ttl = saved.getExpiresAt().toEpochMilli() - before.toEpochMilli(); + assertThat(ttl).isBetween(3_600_000L - 5000, 3_600_000L + 5000); + } + + @Test + void generatePasswordResetTokenUsesPasswordResetTtlAndType() { + when(authTokenProperties.getPasswordResetExpirationMs()).thenReturn(900_000L); + + Instant before = Instant.now(); + service.generatePasswordResetToken(user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + VerificationToken saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(TokenType.PASSWORD_RESET); + long ttl = saved.getExpiresAt().toEpochMilli() - before.toEpochMilli(); + assertThat(ttl).isBetween(900_000L - 5000, 900_000L + 5000); + } + + @Test + void tokensForSameInputAreUniqueAcrossCalls() { + when(authTokenProperties.getVerificationExpirationMs()).thenReturn(1000L); + + String first = service.generateEmailVerificationToken(user); + String second = service.generateEmailVerificationToken(user); + + assertThat(first).isNotEqualTo(second); + } + + @Test + void consumeEmailVerificationMarksTokenUsedAndVerifiesUser() { + when(authTokenProperties.getVerificationExpirationMs()).thenReturn(3_600_000L); + String plaintext = service.generateEmailVerificationToken(user); + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + VerificationToken stored = captor.getValue(); + when(tokenRepository.findValidToken(eq(stored.getTokenHash()), eq(TokenType.EMAIL_VERIFICATION), any())) + .thenReturn(Optional.of(stored)); + + User result = service.consumeEmailVerification(plaintext); + + assertThat(stored.getUsedAt()).isNotNull(); + assertThat(result.isVerified()).isTrue(); + verify(userRepository).save(user); + } + + @Test + void consumeEmailVerificationRejectsInvalidToken() { + when(tokenRepository.findValidToken(any(), eq(TokenType.EMAIL_VERIFICATION), any())) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.consumeEmailVerification("bogus")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid or expired"); + verify(userRepository, never()).save(any()); + } + + @Test + void consumePasswordResetMarksUsedAndReturnsToken() { + VerificationToken token = VerificationToken.builder() + .tokenHash("h").user(user).type(TokenType.PASSWORD_RESET) + .createdAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)).build(); + when(tokenRepository.findValidToken(any(), eq(TokenType.PASSWORD_RESET), any())) + .thenReturn(Optional.of(token)); + + VerificationToken result = service.consumePasswordReset("plain"); + + assertThat(result.getUsedAt()).isNotNull(); + assertThat(result).isSameAs(token); + } + + @Test + void consumePasswordResetRejectsInvalidToken() { + when(tokenRepository.findValidToken(any(), eq(TokenType.PASSWORD_RESET), any())) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.consumePasswordReset("bad")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid or expired password reset"); + } + + @Test + void isValidResetTokenReturnsTrueWhenTokenPresent() { + VerificationToken token = VerificationToken.builder().type(TokenType.PASSWORD_RESET).build(); + when(tokenRepository.findValidToken(any(), eq(TokenType.PASSWORD_RESET), any())) + .thenReturn(Optional.of(token)); + + assertThat(service.isValidResetToken("plain")).isTrue(); + } + + @Test + void isValidResetTokenReturnsFalseWhenAbsent() { + when(tokenRepository.findValidToken(any(), eq(TokenType.PASSWORD_RESET), any())) + .thenReturn(Optional.empty()); + + assertThat(service.isValidResetToken("plain")).isFalse(); + } + + @Test + void consumeEmailVerificationHashesPlaintextWithSha256() { + // Sanity-check the hash contract: lookup must use the SHA-256 hex of the plaintext, + // not the plaintext itself. + when(authTokenProperties.getVerificationExpirationMs()).thenReturn(1000L); + String plaintext = service.generateEmailVerificationToken(user); + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + + String expectedHash = sha256Hex(plaintext); + assertThat(captor.getValue().getTokenHash()).isEqualTo(expectedHash); + } + + private static String sha256Hex(String input) { + try { + var md = java.security.MessageDigest.getInstance("SHA-256"); + return java.util.HexFormat.of().formatHex(md.digest(input.getBytes())); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistryTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistryTest.java new file mode 100644 index 0000000..e5c553e --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionRegistryTest.java @@ -0,0 +1,113 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AgentSessionRegistryTest { + + private AgentSessionRegistry registry; + + @BeforeEach + void setUp() { + registry = new AgentSessionRegistry(); + } + + private AgentSession session() { + WebSocketSession ws = mock(WebSocketSession.class); + lenient().when(ws.isOpen()).thenReturn(true); + return new AgentSession(ws, "challenge", new ObjectMapper()); + } + + @Test + void getReturnsEmptyWhenNoSession() { + assertThat(registry.get(UUID.randomUUID())).isEmpty(); + } + + @Test + void registerThenGetReturnsSession() { + UUID deviceId = UUID.randomUUID(); + AgentSession s = session(); + + registry.register(deviceId, s); + + assertThat(registry.get(deviceId)).containsSame(s); + assertThat(registry.size()).isEqualTo(1); + } + + @Test + void registerSameSessionTwiceDoesNotEvictItself() { + UUID deviceId = UUID.randomUUID(); + AgentSession s = session(); + + registry.register(deviceId, s); + registry.register(deviceId, s); + + assertThat(registry.get(deviceId)).containsSame(s); + // verify(s.getTransport(), never()).close(any(CloseStatus.class)); + } + + @Test + void registerReplacesAndEvictsPriorSession() throws Exception { + UUID deviceId = UUID.randomUUID(); + AgentSession older = session(); + AgentSession newer = session(); + + registry.register(deviceId, older); + registry.register(deviceId, newer); + + assertThat(registry.get(deviceId)).containsSame(newer); + verify(older.getTransport()).close(any(CloseStatus.class)); + } + + @Test + void unregisterRemovesOnlyWhenStillRegistered() { + UUID deviceId = UUID.randomUUID(); + AgentSession s = session(); + registry.register(deviceId, s); + + registry.unregister(deviceId, s); + + assertThat(registry.get(deviceId)).isEmpty(); + assertThat(registry.size()).isZero(); + } + + @Test + void unregisterIsNoOpWhenDifferentSessionRegistered() { + UUID deviceId = UUID.randomUUID(); + AgentSession current = session(); + AgentSession stale = session(); + registry.register(deviceId, current); + + registry.unregister(deviceId, stale); + + assertThat(registry.get(deviceId)).containsSame(current); + } + + @Test + void closeIfPresentRemovesAndClosesSession() throws Exception { + UUID deviceId = UUID.randomUUID(); + AgentSession s = session(); + registry.register(deviceId, s); + + registry.closeIfPresent(deviceId, CloseStatus.GOING_AWAY); + + assertThat(registry.get(deviceId)).isEmpty(); + verify(s.getTransport()).close(any(CloseStatus.class)); + } + + @Test + void closeIfPresentIsNoOpWhenAbsent() { + registry.closeIfPresent(UUID.randomUUID(), CloseStatus.GOING_AWAY); + + assertThat(registry.size()).isZero(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionTest.java new file mode 100644 index 0000000..5fb4d27 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/AgentSessionTest.java @@ -0,0 +1,142 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.board.agent.OutgoingAgentFrame; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AgentSessionTest { + + private WebSocketSession transport; + private AgentSession session; + + @BeforeEach + void setUp() { + transport = mock(WebSocketSession.class); + lenient().when(transport.isOpen()).thenReturn(true); + session = new AgentSession(transport, "the-challenge", new ObjectMapper()); + } + + @Test + void newSessionStartsUnauthWithIdentifiers() { + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.UNAUTH); + assertThat(session.getSessionId()).isNotNull(); + assertThat(session.getChallenge()).isEqualTo("the-challenge"); + assertThat(session.getDeviceId()).isNull(); + } + + @Test + void acceptIncomingSeqRequiresStrictlyIncreasing() { + assertThat(session.acceptIncomingSeq(1)).isTrue(); + assertThat(session.acceptIncomingSeq(2)).isTrue(); + assertThat(session.acceptIncomingSeq(2)).isFalse(); + assertThat(session.acceptIncomingSeq(1)).isFalse(); + assertThat(session.acceptIncomingSeq(3)).isTrue(); + } + + @Test + void markAuthenticatedBindsDeviceAndAdvancesPhase() { + UUID deviceId = UUID.randomUUID(); + + session.markAuthenticated(deviceId); + + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.AUTHED); + assertThat(session.getDeviceId()).isEqualTo(deviceId); + } + + @Test + void markAuthenticatedRejectedWhenNotUnauth() { + session.markAuthenticated(UUID.randomUUID()); + + assertThatThrownBy(() -> session.markAuthenticated(UUID.randomUUID())) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void markClosedSetsClosedPhase() { + session.markClosed(); + + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.CLOSED); + } + + @Test + void populateOutgoingAllocatesMonotonicSeqAndSessionId() { + OutgoingAgentFrame f1 = session.populateOutgoing(OutgoingAgentFrame.builder().type("hello")); + OutgoingAgentFrame f2 = session.populateOutgoing(OutgoingAgentFrame.builder().type("command")); + + assertThat(f1.getSeq()).isEqualTo(1L); + assertThat(f2.getSeq()).isEqualTo(2L); + assertThat(f1.getSessionId()).isEqualTo(session.getSessionId()); + assertThat(f2.getSessionId()).isEqualTo(session.getSessionId()); + } + + @Test + void sendWritesMessageWhenTransportOpen() throws Exception { + OutgoingAgentFrame frame = session.populateOutgoing(OutgoingAgentFrame.builder().type("hello")); + + boolean sent = session.send(frame); + + assertThat(sent).isTrue(); + verify(transport).sendMessage(any(TextMessage.class)); + } + + @Test + void sendReturnsFalseAndMarksClosedWhenTransportClosed() { + when(transport.isOpen()).thenReturn(false); + OutgoingAgentFrame frame = session.populateOutgoing(OutgoingAgentFrame.builder().type("hello")); + + boolean sent = session.send(frame); + + assertThat(sent).isFalse(); + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.CLOSED); + } + + @Test + void sendReturnsFalseAndClosesOnIoException() throws Exception { + doThrow(new IOException("boom")).when(transport).sendMessage(any(TextMessage.class)); + OutgoingAgentFrame frame = session.populateOutgoing(OutgoingAgentFrame.builder().type("hello")); + + boolean sent = session.send(frame); + + assertThat(sent).isFalse(); + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.CLOSED); + } + + @Test + void closeClosesOpenTransportAndMarksClosed() throws Exception { + session.close(CloseStatus.NORMAL); + + verify(transport).close(CloseStatus.NORMAL); + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.CLOSED); + } + + @Test + void closeStillMarksClosedWhenTransportThrows() throws Exception { + doThrow(new IOException("ignored")).when(transport).close(any(CloseStatus.class)); + + session.close(CloseStatus.SERVER_ERROR); + + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.CLOSED); + } + + @Test + void closeSkipsTransportCloseWhenAlreadyClosed() throws Exception { + when(transport.isOpen()).thenReturn(false); + + session.close(CloseStatus.NORMAL); + + verify(transport, never()).close(any(CloseStatus.class)); + assertThat(session.getPhase()).isEqualTo(AgentSession.Phase.CLOSED); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentServiceTest.java new file mode 100644 index 0000000..6c229f9 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentServiceTest.java @@ -0,0 +1,136 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.EnrollmentToken; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DeviceEnrollmentServiceTest { + + @Mock + private EnrollmentTokenService enrollmentTokenService; + @Mock + private DeviceRepository deviceRepository; + @Mock + private BoardConfigRepository boardConfigRepository; + + @InjectMocks + private DeviceEnrollmentService service; + + private String validKeyB64; + + @BeforeEach + void setUp() { + validKeyB64 = Base64.getEncoder().encodeToString(new byte[32]); + lenient().when(deviceRepository.save(any(Device.class))).thenAnswer(inv -> { + Device d = inv.getArgument(0); + if (d.getId() == null) d.setId(UUID.randomUUID()); + return d; + }); + } + + private EnrollmentToken token() { + EnrollmentToken t = new EnrollmentToken(); + t.setId(UUID.randomUUID()); + t.setDisplayName("Brothers Display"); + t.setAudience(Audience.BROTHERS); + t.setCreatedBy("admin@example.com"); + return t; + } + + @Test + void enrollRejectsNonBase64PublicKey() { + var outcome = service.enroll("tok", "!!!not-base64!!!", "host", "model", "v1", "1.2.3.4"); + + assertThat(outcome).isInstanceOf(DeviceEnrollmentService.Outcome.InvalidPublicKey.class); + verifyNoInteractions(enrollmentTokenService, deviceRepository, boardConfigRepository); + } + + @Test + void enrollRejectsWrongLengthPublicKey() { + String shortKey = Base64.getEncoder().encodeToString(new byte[16]); + + var outcome = service.enroll("tok", shortKey, "host", "model", "v1", "1.2.3.4"); + + assertThat(outcome).isInstanceOf(DeviceEnrollmentService.Outcome.InvalidPublicKey.class); + assertThat(((DeviceEnrollmentService.Outcome.InvalidPublicKey) outcome).reason()) + .contains("got 16"); + verifyNoInteractions(enrollmentTokenService); + } + + @Test + void enrollRejectsInvalidToken() { + when(enrollmentTokenService.consume(eq("tok"), any(UUID.class))).thenReturn(Optional.empty()); + + var outcome = service.enroll("tok", validKeyB64, "host", "model", "v1", "1.2.3.4"); + + assertThat(outcome).isInstanceOf(DeviceEnrollmentService.Outcome.InvalidToken.class); + verify(deviceRepository, never()).save(any()); + verify(boardConfigRepository, never()).save(any()); + } + + @Test + void enrollHappyPathPersistsDeviceWithDecodedKeyAndMetadata() { + when(enrollmentTokenService.consume(anyString(), any(UUID.class))).thenReturn(Optional.of(token())); + + var outcome = service.enroll("tok", validKeyB64, "kiosk-7", "RPi4", "agent-2.0", "10.0.0.5"); + + assertThat(outcome).isInstanceOf(DeviceEnrollmentService.Outcome.Ok.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Device.class); + verify(deviceRepository).save(captor.capture()); + Device saved = captor.getValue(); + assertThat(saved.getDisplayName()).isEqualTo("Brothers Display (kiosk-7)"); + assertThat(saved.getAudience()).isEqualTo(Audience.BROTHERS); + assertThat(saved.getPublicKey()).hasSize(32); + assertThat(saved.getHardwareModel()).isEqualTo("RPi4"); + assertThat(saved.getAgentVersion()).isEqualTo("agent-2.0"); + assertThat(saved.getLastSeenIp()).isEqualTo("10.0.0.5"); + assertThat(saved.getEnrolledAt()).isNotNull(); + } + + @Test + void enrollUsesTokenDisplayNameWhenHostnameBlank() { + when(enrollmentTokenService.consume(anyString(), any(UUID.class))).thenReturn(Optional.of(token())); + + service.enroll("tok", validKeyB64, " ", "model", "v1", "ip"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Device.class); + verify(deviceRepository).save(captor.capture()); + assertThat(captor.getValue().getDisplayName()).isEqualTo("Brothers Display"); + } + + @Test + void enrollWritesBackConsumedDeviceIdAndDefaultConfig() { + EnrollmentToken tok = token(); + when(enrollmentTokenService.consume(anyString(), any(UUID.class))).thenReturn(Optional.of(tok)); + + var outcome = service.enroll("tok", validKeyB64, "host", "model", "v1", "ip"); + + Device saved = ((DeviceEnrollmentService.Outcome.Ok) outcome).device(); + assertThat(tok.getConsumedByDeviceId()).isEqualTo(saved.getId()); + + ArgumentCaptor cfg = ArgumentCaptor.forClass(DeviceConfig.class); + verify(boardConfigRepository).save(cfg.capture()); + assertThat(cfg.getValue().getDevice()).isSameAs(saved); + assertThat(cfg.getValue().getLocation().getCity()).isEqualTo("Mississauga"); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java new file mode 100644 index 0000000..03b892c --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java @@ -0,0 +1,137 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.DeviceTelemetry; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceTelemetryRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class HeartbeatServiceTest { + + @Mock + private DeviceRepository deviceRepository; + @Mock + private DeviceTelemetryRepository telemetryRepository; + @Mock + private DeviceEventPublisher events; + + @InjectMocks + private HeartbeatService service; + + private HeartbeatFrame frameWithTelemetry() { + HeartbeatFrame frame = new HeartbeatFrame(); + HeartbeatFrame.Telemetry t = new HeartbeatFrame.Telemetry(); + t.setUptimeSec(1234L); + t.setCpuTempC(55.5); + t.setThrottleFlags("0x0"); + t.setMemUsedMb(512); + t.setMemTotalMb(2048); + t.setDiskUsedPct(40); + t.setKioskAlive(true); + t.setIpv4(List.of("10.0.0.1", "192.168.1.2")); + t.setWifiSsid("MSA-WIFI"); + t.setDisplayedFrameId(UUID.randomUUID()); + frame.setTelemetry(t); + return frame; + } + + @Test + void recordUpdatesLastHeartbeatAndIpForKnownDevice() { + UUID deviceId = UUID.randomUUID(); + Device device = Device.builder().id(deviceId).displayName("d").build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + + Instant before = Instant.now(); + service.record(deviceId, frameWithTelemetry(), "203.0.113.9"); + + assertThat(device.getLastHeartbeat()).isNotNull(); + assertThat(device.getLastHeartbeat()).isAfterOrEqualTo(before); + assertThat(device.getLastSeenIp()).isEqualTo("203.0.113.9"); + verify(deviceRepository).save(device); + } + + @Test + void recordDoesNotOverwriteIpWhenRemoteIpBlank() { + UUID deviceId = UUID.randomUUID(); + Device device = Device.builder().id(deviceId).displayName("d").lastSeenIp("1.1.1.1").build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + + service.record(deviceId, frameWithTelemetry(), " "); + + assertThat(device.getLastSeenIp()).isEqualTo("1.1.1.1"); + } + + @Test + void recordToleratesUnknownDeviceButStillPersistsTelemetry() { + UUID deviceId = UUID.randomUUID(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.empty()); + + service.record(deviceId, frameWithTelemetry(), "ip"); + + verify(deviceRepository, never()).save(any()); + verify(telemetryRepository).save(any(DeviceTelemetry.class)); + verify(events).heartbeat(eq(deviceId), any()); + } + + @Test + void recordPersistsTelemetryRowWithMappedFields() { + UUID deviceId = UUID.randomUUID(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.empty()); + HeartbeatFrame frame = frameWithTelemetry(); + + service.record(deviceId, frame, "ip"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeviceTelemetry.class); + verify(telemetryRepository).save(captor.capture()); + DeviceTelemetry row = captor.getValue(); + assertThat(row.getDeviceId()).isEqualTo(deviceId); + assertThat(row.getRecordedAt()).isNotNull(); + assertThat(row.getUptimeSec()).isEqualTo(1234L); + assertThat(row.getCpuTempC()).isEqualTo(55.5); + assertThat(row.getIpv4()).isEqualTo("10.0.0.1,192.168.1.2"); + assertThat(row.getWifiSsid()).isEqualTo("MSA-WIFI"); + } + + @Test + void recordSkipsTelemetryAndEventWhenTelemetryNull() { + UUID deviceId = UUID.randomUUID(); + Device device = Device.builder().id(deviceId).displayName("d").build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + HeartbeatFrame frame = new HeartbeatFrame(); + + service.record(deviceId, frame, "ip"); + + verify(deviceRepository).save(device); + verifyNoInteractions(telemetryRepository); + verifyNoInteractions(events); + } + + @Test + void recordPublishesHeartbeatEventAfterPersist() { + UUID deviceId = UUID.randomUUID(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.empty()); + HeartbeatFrame frame = frameWithTelemetry(); + + service.record(deviceId, frame, "ip"); + + verify(events).heartbeat(deviceId, frame); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java new file mode 100644 index 0000000..66fd107 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java @@ -0,0 +1,141 @@ +package com.ibrasoft.lensbridge.service.board; + +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.Location; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import org.junit.jupiter.api.Test; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class BoardContextTest { + + private static Device deviceWithTimezone(String timezone) { + Location location = Location.builder().timezone(timezone).build(); + DeviceConfig config = DeviceConfig.builder().location(location).build(); + Device device = Device.builder().displayName("d").build(); + device.setConfig(config); + return device; + } + + @Test + void zoneResolvesFromConfigTimezone() { + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("America/Toronto")); + BoardContext ctx = BoardContext.builder() + .device(deviceWithTimezone("America/Toronto")) + .config(deviceWithTimezone("America/Toronto").getConfig()) + .now(now) + .build(); + + assertThat(ctx.zone()).isEqualTo(ZoneId.of("America/Toronto")); + } + + @Test + void zoneFallsBackToSystemDefaultWhenConfigNull() { + BoardContext ctx = BoardContext.builder() + .device(Device.builder().displayName("d").build()) + .config(null) + .now(ZonedDateTime.now()) + .build(); + + assertThat(ctx.zone()).isEqualTo(ZoneId.systemDefault()); + } + + @Test + void zoneFallsBackToSystemDefaultWhenTimezoneInvalid() { + Location location = Location.builder().timezone("Not/AZone").build(); + DeviceConfig config = DeviceConfig.builder().location(location).build(); + + BoardContext ctx = BoardContext.builder() + .device(Device.builder().displayName("d").build()) + .config(config) + .now(ZonedDateTime.now()) + .build(); + + assertThat(ctx.zone()).isEqualTo(ZoneId.systemDefault()); + } + + @Test + void currentWeekStartIsPreviousOrSameSundayAtMidnight() { + // Wednesday 2026-05-13 12:34 UTC + ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); + BoardContext ctx = BoardContext.builder().now(now).build(); + + ZonedDateTime weekStart = ctx.currentWeekStart().atZone(ZoneOffset.UTC); + + assertThat(weekStart.getDayOfWeek()).isEqualTo(DayOfWeek.SUNDAY); + assertThat(weekStart.toLocalDate()).isEqualTo(now.toLocalDate().minusDays(3)); + assertThat(weekStart.getHour()).isZero(); + assertThat(weekStart.getMinute()).isZero(); + } + + @Test + void currentWeekEndIsNextOrSameSaturdayEndOfDay() { + ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); + BoardContext ctx = BoardContext.builder().now(now).build(); + + ZonedDateTime weekEnd = ctx.currentWeekEnd().atZone(ZoneOffset.UTC); + + assertThat(weekEnd.getDayOfWeek()).isEqualTo(DayOfWeek.SATURDAY); + assertThat(weekEnd.toLocalDate()).isEqualTo(now.toLocalDate().plusDays(3)); + assertThat(weekEnd.getHour()).isEqualTo(23); + assertThat(weekEnd.getMinute()).isEqualTo(59); + } + + @Test + void weekStartIsBeforeWeekEnd() { + ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); + BoardContext ctx = BoardContext.builder().now(now).build(); + + assertThat(ctx.currentWeekStart()).isBefore(ctx.currentWeekEnd()); + } + + @Test + void currentDayStartAndEndBracketTheMoment() { + ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); + BoardContext ctx = BoardContext.builder().now(now).build(); + + Instant dayStart = ctx.currentDayStart(); + Instant dayEnd = ctx.currentDayEnd(); + + assertThat(dayStart).isBeforeOrEqualTo(now.toInstant()); + assertThat(dayEnd).isAfter(now.toInstant()); + assertThat(dayStart.atZone(ZoneOffset.UTC).toLocalDate()) + .isEqualTo(now.toLocalDate()); + } + + @Test + void ofBuildsContextFromDeviceConfigTimezone() { + Device device = deviceWithTimezone("America/Toronto"); + + BoardContext ctx = BoardContext.of(device); + + assertThat(ctx.getDevice()).isSameAs(device); + assertThat(ctx.getConfig()).isSameAs(device.getConfig()); + assertThat(ctx.getNow().getZone()).isEqualTo(ZoneId.of("America/Toronto")); + } + + @Test + void ofUsesSystemDefaultWhenConfigMissing() { + Device device = Device.builder().displayName("d").build(); + + BoardContext ctx = BoardContext.of(device); + + assertThat(ctx.getConfig()).isNull(); + assertThat(ctx.getNow().getZone()).isEqualTo(ZoneId.systemDefault()); + } + + @Test + void ofFallsBackToSystemDefaultWhenTimezoneInvalid() { + Device device = deviceWithTimezone("Bogus/Zone"); + + BoardContext ctx = BoardContext.of(device); + + assertThat(ctx.getNow().getZone()).isEqualTo(ZoneId.systemDefault()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java new file mode 100644 index 0000000..88b4d16 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java @@ -0,0 +1,92 @@ +package com.ibrasoft.lensbridge.service.board.transformer; + +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class DailyScheduleFrameTransformerTest { + + @InjectMocks + private DailyScheduleFrameTransformer transformer; + + private BoardEvent event(String name) { + return BoardEvent.builder() + .name(name) + .description("desc-" + name) + .location("loc-" + name) + .startTime(Instant.parse("2026-05-15T09:00:00Z")) + .endTime(Instant.parse("2026-05-15T10:00:00Z")) + .allDay(false) + .audience(Audience.BOTH) + .build(); + } + + @Test + void supportsReturnsDailySchedule() { + assertThat(transformer.supports()).isEqualTo(FrameType.DAILY_SCHEDULE); + } + + @Test + void transformProducesDailyScheduleFrameWithExpectedShell() { + FrameDefinition def = transformer.transform(List.of(event("A")), null); + + assertThat(def.getFrameType()).isEqualTo(FrameType.DAILY_SCHEDULE); + assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); + assertThat(def.getDurationInSeconds()).isNull(); + assertThat(def.getPriority()).isNull(); + assertThat(def.getFrameConfig()).isInstanceOf(DailyScheduleFrameConfig.class); + } + + @Test + void transformMapsEventFieldsIntoEventViews() { + BoardEvent e = event("Lecture"); + + FrameDefinition def = transformer.transform(List.of(e), null); + + DailyScheduleFrameConfig config = (DailyScheduleFrameConfig) def.getFrameConfig(); + assertThat(config.getHeading()).isEqualTo("Today"); + assertThat(config.getEvents()).hasSize(1); + EventView view = config.getEvents().get(0); + assertThat(view.getName()).isEqualTo("Lecture"); + assertThat(view.getDescription()).isEqualTo("desc-Lecture"); + assertThat(view.getLocation()).isEqualTo("loc-Lecture"); + assertThat(view.getStartTime()).isEqualTo(Instant.parse("2026-05-15T09:00:00Z")); + assertThat(view.getEndTime()).isEqualTo(Instant.parse("2026-05-15T10:00:00Z")); + assertThat(view.getAllDay()).isFalse(); + } + + @Test + void transformPreservesEventOrderAndCount() { + FrameDefinition def = transformer.transform(List.of(event("A"), event("B"), event("C")), null); + + DailyScheduleFrameConfig config = (DailyScheduleFrameConfig) def.getFrameConfig(); + assertThat(config.getEvents()) + .extracting(EventView::getName) + .containsExactly("A", "B", "C"); + } + + @Test + void transformWithEmptyListProducesEmptyEventsButValidFrame() { + FrameDefinition def = transformer.transform(Collections.emptyList(), null); + + DailyScheduleFrameConfig config = (DailyScheduleFrameConfig) def.getFrameConfig(); + assertThat(config.getHeading()).isEqualTo("Today"); + assertThat(config.getEvents()).isEmpty(); + assertThat(def.getFrameType()).isEqualTo(FrameType.DAILY_SCHEDULE); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java new file mode 100644 index 0000000..304b7d6 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java @@ -0,0 +1,91 @@ +package com.ibrasoft.lensbridge.service.board.transformer; + +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class EventListFrameTransformerTest { + + @InjectMocks + private EventListFrameTransformer transformer; + + private BoardEvent event(String name, boolean allDay) { + return BoardEvent.builder() + .name(name) + .description("desc-" + name) + .location("loc-" + name) + .startTime(Instant.parse("2026-05-12T00:00:00Z")) + .endTime(Instant.parse("2026-05-12T23:59:59Z")) + .allDay(allDay) + .audience(Audience.BROTHERS) + .build(); + } + + @Test + void supportsReturnsEventList() { + assertThat(transformer.supports()).isEqualTo(FrameType.EVENT_LIST); + } + + @Test + void transformProducesEventListFrameWithExpectedShell() { + FrameDefinition def = transformer.transform(List.of(event("A", false)), null); + + assertThat(def.getFrameType()).isEqualTo(FrameType.EVENT_LIST); + assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); + assertThat(def.getDurationInSeconds()).isNull(); + assertThat(def.getPriority()).isNull(); + assertThat(def.getFrameConfig()).isInstanceOf(EventListFrameConfig.class); + } + + @Test + void transformMapsEventFieldsIncludingAllDay() { + FrameDefinition def = transformer.transform(List.of(event("Conference", true)), null); + + EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); + assertThat(config.getHeading()).isEqualTo("This Week"); + assertThat(config.getEvents()).hasSize(1); + EventView view = config.getEvents().get(0); + assertThat(view.getName()).isEqualTo("Conference"); + assertThat(view.getDescription()).isEqualTo("desc-Conference"); + assertThat(view.getLocation()).isEqualTo("loc-Conference"); + assertThat(view.getStartTime()).isEqualTo(Instant.parse("2026-05-12T00:00:00Z")); + assertThat(view.getEndTime()).isEqualTo(Instant.parse("2026-05-12T23:59:59Z")); + assertThat(view.getAllDay()).isTrue(); + } + + @Test + void transformPreservesEventOrderAndCount() { + FrameDefinition def = transformer.transform( + List.of(event("A", false), event("B", false)), null); + + EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); + assertThat(config.getEvents()) + .extracting(EventView::getName) + .containsExactly("A", "B"); + } + + @Test + void transformWithEmptyListProducesEmptyEventsButValidFrame() { + FrameDefinition def = transformer.transform(Collections.emptyList(), null); + + EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); + assertThat(config.getHeading()).isEqualTo("This Week"); + assertThat(config.getEvents()).isEmpty(); + assertThat(def.getFrameType()).isEqualTo(FrameType.EVENT_LIST); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java new file mode 100644 index 0000000..33c20fd --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java @@ -0,0 +1,71 @@ +package com.ibrasoft.lensbridge.service.board.transformer; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Poster; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.PosterFrameConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class PosterFrameTransformerTest { + + @InjectMocks + private PosterFrameTransformer transformer; + + private Poster poster(String title, String image, int duration) { + return Poster.builder() + .title(title) + .image(image) + .duration(duration) + .startTime(Instant.parse("2026-05-15T00:00:00Z")) + .endTime(Instant.parse("2026-05-20T00:00:00Z")) + .audience(Audience.BOTH) + .build(); + } + + @Test + void supportsReturnsPoster() { + assertThat(transformer.supports()).isEqualTo(FrameType.POSTER); + } + + @Test + void transformMapsPosterFieldsAndDuration() { + FrameDefinition def = transformer.transform(poster("Ramadan", "https://img/r.png", 15), null); + + assertThat(def.getFrameType()).isEqualTo(FrameType.POSTER); + assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); + assertThat(def.getPriority()).isNull(); + assertThat(def.getDurationInSeconds()).isEqualTo(15); + assertThat(def.getFrameConfig()).isInstanceOf(PosterFrameConfig.class); + + PosterFrameConfig config = (PosterFrameConfig) def.getFrameConfig(); + assertThat(config.getPosterUrl()).isEqualTo("https://img/r.png"); + assertThat(config.getTitle()).isEqualTo("Ramadan"); + } + + @Test + void transformWithZeroDurationStillSetsDuration() { + FrameDefinition def = transformer.transform(poster("Z", "u", 0), null); + + assertThat(def.getDurationInSeconds()).isEqualTo(0); + } + + @Test + void transformWithNullTitleAndImageProducesNullConfigFields() { + FrameDefinition def = transformer.transform(poster(null, null, 10), null); + + PosterFrameConfig config = (PosterFrameConfig) def.getFrameConfig(); + assertThat(config.getPosterUrl()).isNull(); + assertThat(config.getTitle()).isNull(); + assertThat(def.getDurationInSeconds()).isEqualTo(10); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java new file mode 100644 index 0000000..bc07435 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java @@ -0,0 +1,171 @@ +package com.ibrasoft.lensbridge.service.board.transformer; + +import com.ibrasoft.lensbridge.model.board.IslamicQuote; +import com.ibrasoft.lensbridge.model.board.JummahPrayer; +import com.ibrasoft.lensbridge.model.board.WeeklyContent; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.IslamicQuoteFrameConfig; +import com.ibrasoft.lensbridge.model.board.frames.JummahFrameConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class WeeklyContentFrameTransformerTest { + + @InjectMocks + private WeeklyContentFrameTransformer transformer; + + private IslamicQuote quote(IslamicQuote.Kind kind, String ref) { + return IslamicQuote.builder() + .kind(kind) + .arabic("arabic-" + ref) + .transliteration("translit-" + ref) + .translation("translation-" + ref) + .reference(ref) + .build(); + } + + private JummahPrayer prayer(LocalTime time, String khatib, String room) { + return JummahPrayer.builder() + .prayerTime(time) + .khatib(khatib) + .room(room) + .build(); + } + + @Test + void nullContentReturnsEmptyList() { + assertThat(transformer.transform(null, null)).isEmpty(); + } + + @Test + void emptyContentReturnsEmptyList() { + WeeklyContent content = WeeklyContent.builder().build(); + + assertThat(transformer.transform(content, null)).isEmpty(); + } + + @Test + void verseQuoteMapsToVerseKindFrame() { + WeeklyContent content = WeeklyContent.builder() + .quotes(List.of(quote(IslamicQuote.Kind.VERSE, "2:255"))) + .build(); + + List frames = transformer.transform(content, null); + + assertThat(frames).hasSize(1); + FrameDefinition def = frames.get(0); + assertThat(def.getFrameType()).isEqualTo(FrameType.ISLAMIC_QUOTE); + assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); + assertThat(def.getDurationInSeconds()).isNull(); + assertThat(def.getPriority()).isNull(); + + IslamicQuoteFrameConfig config = (IslamicQuoteFrameConfig) def.getFrameConfig(); + assertThat(config.getKind()).isEqualTo(IslamicQuoteFrameConfig.Kind.VERSE); + assertThat(config.getArabic()).isEqualTo("arabic-2:255"); + assertThat(config.getTransliteration()).isEqualTo("translit-2:255"); + assertThat(config.getTranslation()).isEqualTo("translation-2:255"); + assertThat(config.getReference()).isEqualTo("2:255"); + } + + @Test + void hadithQuoteMapsToHadithKindFrame() { + WeeklyContent content = WeeklyContent.builder() + .quotes(List.of(quote(IslamicQuote.Kind.HADITH, "Bukhari 1"))) + .build(); + + List frames = transformer.transform(content, null); + + IslamicQuoteFrameConfig config = (IslamicQuoteFrameConfig) frames.get(0).getFrameConfig(); + assertThat(config.getKind()).isEqualTo(IslamicQuoteFrameConfig.Kind.HADITH); + } + + @Test + void nullKindQuoteFallsBackToHadith() { + WeeklyContent content = WeeklyContent.builder() + .quotes(List.of(quote(null, "unknown"))) + .build(); + + List frames = transformer.transform(content, null); + + IslamicQuoteFrameConfig config = (IslamicQuoteFrameConfig) frames.get(0).getFrameConfig(); + assertThat(config.getKind()).isEqualTo(IslamicQuoteFrameConfig.Kind.HADITH); + } + + @Test + void multipleQuotesProduceOneFramePerQuoteInOrder() { + WeeklyContent content = WeeklyContent.builder() + .quotes(List.of( + quote(IslamicQuote.Kind.VERSE, "Q1"), + quote(IslamicQuote.Kind.HADITH, "Q2"))) + .build(); + + List frames = transformer.transform(content, null); + + assertThat(frames).hasSize(2); + assertThat(((IslamicQuoteFrameConfig) frames.get(0).getFrameConfig()).getReference()).isEqualTo("Q1"); + assertThat(((IslamicQuoteFrameConfig) frames.get(1).getFrameConfig()).getReference()).isEqualTo("Q2"); + } + + @Test + void jummahPrayersProduceSingleJummahFrameWithSlots() { + WeeklyContent content = WeeklyContent.builder() + .jummahPrayers(List.of( + prayer(LocalTime.of(13, 30), "Imam A", "Hall 1"), + prayer(LocalTime.of(14, 30), "Imam B", "Hall 2"))) + .build(); + + List frames = transformer.transform(content, null); + + assertThat(frames).hasSize(1); + FrameDefinition def = frames.get(0); + assertThat(def.getFrameType()).isEqualTo(FrameType.JUMMAH); + assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); + + JummahFrameConfig config = (JummahFrameConfig) def.getFrameConfig(); + assertThat(config.getPrayers()).hasSize(2); + JummahFrameConfig.JummahSlot first = config.getPrayers().get(0); + assertThat(first.getPrayerTime()).isEqualTo("13:30"); + assertThat(first.getKhatib()).isEqualTo("Imam A"); + assertThat(first.getRoom()).isEqualTo("Hall 1"); + assertThat(config.getPrayers().get(1).getPrayerTime()).isEqualTo("14:30"); + } + + @Test + void jummahSlotWithNullPrayerTimeYieldsNullStringTime() { + WeeklyContent content = WeeklyContent.builder() + .jummahPrayers(List.of(prayer(null, "Imam", "Room"))) + .build(); + + List frames = transformer.transform(content, null); + + JummahFrameConfig config = (JummahFrameConfig) frames.get(0).getFrameConfig(); + assertThat(config.getPrayers().get(0).getPrayerTime()).isNull(); + } + + @Test + void quotesAndJummahProduceQuoteFramesThenJummahFrame() { + List prayers = new ArrayList<>(); + prayers.add(prayer(LocalTime.of(13, 0), "K", "R")); + WeeklyContent content = WeeklyContent.builder() + .quotes(List.of(quote(IslamicQuote.Kind.VERSE, "V"))) + .jummahPrayers(prayers) + .build(); + + List frames = transformer.transform(content, null); + + assertThat(frames).hasSize(2); + assertThat(frames.get(0).getFrameType()).isEqualTo(FrameType.ISLAMIC_QUOTE); + assertThat(frames.get(1).getFrameType()).isEqualTo(FrameType.JUMMAH); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/util/PatchTest.java b/src/test/java/com/ibrasoft/lensbridge/util/PatchTest.java new file mode 100644 index 0000000..6cfc44d --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/util/PatchTest.java @@ -0,0 +1,40 @@ +package com.ibrasoft.lensbridge.util; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +class PatchTest { + + @Test + void applyInvokesSetterWhenValueIsNonNull() { + AtomicReference sink = new AtomicReference<>("original"); + + Patch.apply("new", sink::set); + + assertThat(sink.get()).isEqualTo("new"); + } + + @Test + void applyDoesNotInvokeSetterWhenValueIsNull() { + AtomicReference sink = new AtomicReference<>("original"); + + Patch.apply(null, sink::set); + + assertThat(sink.get()).isEqualTo("original"); + } + + @Test + void applyForwardsExactValueToSetter() { + Object value = new Object(); + AtomicReference captured = new AtomicReference<>(); + Consumer setter = captured::set; + + Patch.apply(value, setter); + + assertThat(captured.get()).isSameAs(value); + } +} From 1ea1949fc6137e29acf540e6a4bed02613226c49 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 17 Jun 2026 11:56:50 -0400 Subject: [PATCH 28/52] about to check out --- .claude/settings.local.json | 6 +- .claude/worktrees/agent-a09e2540ed87a7bc2 | 1 + .claude/worktrees/agent-a0b277ea65c3bdb5a | 1 + .claude/worktrees/agent-a0f8461f1447b024d | 1 + .claude/worktrees/agent-a1b682a0b3fa2de24 | 1 + .claude/worktrees/agent-a1c5ba1c7fe4880e8 | 1 + .claude/worktrees/agent-a25e53d6d6c243536 | 1 + .claude/worktrees/agent-a2c7bb6b3b5b29490 | 1 + .claude/worktrees/agent-a3c71433aeb464d2a | 1 + .claude/worktrees/agent-a3cfd09e71c1f5bfc | 1 + .claude/worktrees/agent-a3f7af3a31c5506b3 | 1 + .claude/worktrees/agent-a4032405dca8687a2 | 1 + .claude/worktrees/agent-a4621e55991a7d914 | 1 + .claude/worktrees/agent-a512c5e42a6c35f63 | 1 + .claude/worktrees/agent-a661b8945be2ef540 | 1 + .claude/worktrees/agent-a689c9982ade3a881 | 1 + .claude/worktrees/agent-a6c085bbee5fce37a | 1 + .claude/worktrees/agent-a7376fc72b8daa46b | 1 + .claude/worktrees/agent-a7bdb3501c7bace18 | 1 + .claude/worktrees/agent-a7c3e77e0220e1a41 | 1 + .claude/worktrees/agent-a7df6510a299b45ce | 1 + .claude/worktrees/agent-a895cecdf99f398f4 | 1 + .claude/worktrees/agent-a8d8e69975ec63e27 | 1 + .claude/worktrees/agent-a9ec14a214df2c8ec | 1 + .claude/worktrees/agent-aae6b1aea0d80eef3 | 1 + .claude/worktrees/agent-ab45f26720d622356 | 1 + .claude/worktrees/agent-abe79206221eedf92 | 1 + .claude/worktrees/agent-ac402729bb2a4badf | 1 + .claude/worktrees/agent-ac5fbcfb55851b8f2 | 1 + .claude/worktrees/agent-acb44a169029ab34e | 1 + .claude/worktrees/agent-ae1fa9c469f42d7e2 | 1 + .claude/worktrees/agent-ae3365cae695557d3 | 1 + .claude/worktrees/agent-ae38e5d8ac177ad07 | 1 + .claude/worktrees/agent-ae9e13f6071e1a8da | 1 + .claude/worktrees/agent-aed0bc540b62160e9 | 1 + .claude/worktrees/agent-afccdbae8548f4078 | 1 + .claude/worktrees/agent-afe061bd188822c8b | 1 + pom.xml | 8 ++ .../lensbridge/LensBridgeApplication.java | 2 + .../board/response/MusallahBoardPayload.java | 2 + .../service/OpenWeatherService.java | 81 +++++++++++++++++++ .../service/board/BoardPayloadAssembler.java | 3 + 42 files changed, 137 insertions(+), 1 deletion(-) create mode 160000 .claude/worktrees/agent-a09e2540ed87a7bc2 create mode 160000 .claude/worktrees/agent-a0b277ea65c3bdb5a create mode 160000 .claude/worktrees/agent-a0f8461f1447b024d create mode 160000 .claude/worktrees/agent-a1b682a0b3fa2de24 create mode 160000 .claude/worktrees/agent-a1c5ba1c7fe4880e8 create mode 160000 .claude/worktrees/agent-a25e53d6d6c243536 create mode 160000 .claude/worktrees/agent-a2c7bb6b3b5b29490 create mode 160000 .claude/worktrees/agent-a3c71433aeb464d2a create mode 160000 .claude/worktrees/agent-a3cfd09e71c1f5bfc create mode 160000 .claude/worktrees/agent-a3f7af3a31c5506b3 create mode 160000 .claude/worktrees/agent-a4032405dca8687a2 create mode 160000 .claude/worktrees/agent-a4621e55991a7d914 create mode 160000 .claude/worktrees/agent-a512c5e42a6c35f63 create mode 160000 .claude/worktrees/agent-a661b8945be2ef540 create mode 160000 .claude/worktrees/agent-a689c9982ade3a881 create mode 160000 .claude/worktrees/agent-a6c085bbee5fce37a create mode 160000 .claude/worktrees/agent-a7376fc72b8daa46b create mode 160000 .claude/worktrees/agent-a7bdb3501c7bace18 create mode 160000 .claude/worktrees/agent-a7c3e77e0220e1a41 create mode 160000 .claude/worktrees/agent-a7df6510a299b45ce create mode 160000 .claude/worktrees/agent-a895cecdf99f398f4 create mode 160000 .claude/worktrees/agent-a8d8e69975ec63e27 create mode 160000 .claude/worktrees/agent-a9ec14a214df2c8ec create mode 160000 .claude/worktrees/agent-aae6b1aea0d80eef3 create mode 160000 .claude/worktrees/agent-ab45f26720d622356 create mode 160000 .claude/worktrees/agent-abe79206221eedf92 create mode 160000 .claude/worktrees/agent-ac402729bb2a4badf create mode 160000 .claude/worktrees/agent-ac5fbcfb55851b8f2 create mode 160000 .claude/worktrees/agent-acb44a169029ab34e create mode 160000 .claude/worktrees/agent-ae1fa9c469f42d7e2 create mode 160000 .claude/worktrees/agent-ae3365cae695557d3 create mode 160000 .claude/worktrees/agent-ae38e5d8ac177ad07 create mode 160000 .claude/worktrees/agent-ae9e13f6071e1a8da create mode 160000 .claude/worktrees/agent-aed0bc540b62160e9 create mode 160000 .claude/worktrees/agent-afccdbae8548f4078 create mode 160000 .claude/worktrees/agent-afe061bd188822c8b create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 34b72a0..ce9aff9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,11 @@ "PowerShell(.\\\\mvnw.cmd test-compile 2>&1 | Select-Object -Last 40)", "Bash(go build *)", "Bash(go vet *)", - "Bash(wsl *)" + "Bash(wsl *)", + "Bash(Select-Object -First 50)", + "Bash(gh pr *)", + "Read(//tmp/**)", + "Bash(Select-Object Name)" ] } } diff --git a/.claude/worktrees/agent-a09e2540ed87a7bc2 b/.claude/worktrees/agent-a09e2540ed87a7bc2 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a09e2540ed87a7bc2 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a0b277ea65c3bdb5a b/.claude/worktrees/agent-a0b277ea65c3bdb5a new file mode 160000 index 0000000..5932344 --- /dev/null +++ b/.claude/worktrees/agent-a0b277ea65c3bdb5a @@ -0,0 +1 @@ +Subproject commit 5932344b90593746346f790c7a6e8388781a9cde diff --git a/.claude/worktrees/agent-a0f8461f1447b024d b/.claude/worktrees/agent-a0f8461f1447b024d new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a0f8461f1447b024d @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a1b682a0b3fa2de24 b/.claude/worktrees/agent-a1b682a0b3fa2de24 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a1b682a0b3fa2de24 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a1c5ba1c7fe4880e8 b/.claude/worktrees/agent-a1c5ba1c7fe4880e8 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a1c5ba1c7fe4880e8 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a25e53d6d6c243536 b/.claude/worktrees/agent-a25e53d6d6c243536 new file mode 160000 index 0000000..5932344 --- /dev/null +++ b/.claude/worktrees/agent-a25e53d6d6c243536 @@ -0,0 +1 @@ +Subproject commit 5932344b90593746346f790c7a6e8388781a9cde diff --git a/.claude/worktrees/agent-a2c7bb6b3b5b29490 b/.claude/worktrees/agent-a2c7bb6b3b5b29490 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a2c7bb6b3b5b29490 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a3c71433aeb464d2a b/.claude/worktrees/agent-a3c71433aeb464d2a new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a3c71433aeb464d2a @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a3cfd09e71c1f5bfc b/.claude/worktrees/agent-a3cfd09e71c1f5bfc new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a3cfd09e71c1f5bfc @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a3f7af3a31c5506b3 b/.claude/worktrees/agent-a3f7af3a31c5506b3 new file mode 160000 index 0000000..5932344 --- /dev/null +++ b/.claude/worktrees/agent-a3f7af3a31c5506b3 @@ -0,0 +1 @@ +Subproject commit 5932344b90593746346f790c7a6e8388781a9cde diff --git a/.claude/worktrees/agent-a4032405dca8687a2 b/.claude/worktrees/agent-a4032405dca8687a2 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a4032405dca8687a2 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a4621e55991a7d914 b/.claude/worktrees/agent-a4621e55991a7d914 new file mode 160000 index 0000000..c2d984b --- /dev/null +++ b/.claude/worktrees/agent-a4621e55991a7d914 @@ -0,0 +1 @@ +Subproject commit c2d984b2440cc24eb5f89c25b256a9cca3b2a0e4 diff --git a/.claude/worktrees/agent-a512c5e42a6c35f63 b/.claude/worktrees/agent-a512c5e42a6c35f63 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a512c5e42a6c35f63 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a661b8945be2ef540 b/.claude/worktrees/agent-a661b8945be2ef540 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a661b8945be2ef540 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a689c9982ade3a881 b/.claude/worktrees/agent-a689c9982ade3a881 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a689c9982ade3a881 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a6c085bbee5fce37a b/.claude/worktrees/agent-a6c085bbee5fce37a new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a6c085bbee5fce37a @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7376fc72b8daa46b b/.claude/worktrees/agent-a7376fc72b8daa46b new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a7376fc72b8daa46b @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7bdb3501c7bace18 b/.claude/worktrees/agent-a7bdb3501c7bace18 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a7bdb3501c7bace18 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7c3e77e0220e1a41 b/.claude/worktrees/agent-a7c3e77e0220e1a41 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a7c3e77e0220e1a41 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7df6510a299b45ce b/.claude/worktrees/agent-a7df6510a299b45ce new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a7df6510a299b45ce @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a895cecdf99f398f4 b/.claude/worktrees/agent-a895cecdf99f398f4 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a895cecdf99f398f4 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a8d8e69975ec63e27 b/.claude/worktrees/agent-a8d8e69975ec63e27 new file mode 160000 index 0000000..4fb6862 --- /dev/null +++ b/.claude/worktrees/agent-a8d8e69975ec63e27 @@ -0,0 +1 @@ +Subproject commit 4fb6862dc5f9af72c2dff72877d064d71e8d73ef diff --git a/.claude/worktrees/agent-a9ec14a214df2c8ec b/.claude/worktrees/agent-a9ec14a214df2c8ec new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-a9ec14a214df2c8ec @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-aae6b1aea0d80eef3 b/.claude/worktrees/agent-aae6b1aea0d80eef3 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-aae6b1aea0d80eef3 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ab45f26720d622356 b/.claude/worktrees/agent-ab45f26720d622356 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-ab45f26720d622356 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-abe79206221eedf92 b/.claude/worktrees/agent-abe79206221eedf92 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-abe79206221eedf92 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ac402729bb2a4badf b/.claude/worktrees/agent-ac402729bb2a4badf new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-ac402729bb2a4badf @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ac5fbcfb55851b8f2 b/.claude/worktrees/agent-ac5fbcfb55851b8f2 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-ac5fbcfb55851b8f2 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-acb44a169029ab34e b/.claude/worktrees/agent-acb44a169029ab34e new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-acb44a169029ab34e @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ae1fa9c469f42d7e2 b/.claude/worktrees/agent-ae1fa9c469f42d7e2 new file mode 160000 index 0000000..60308b2 --- /dev/null +++ b/.claude/worktrees/agent-ae1fa9c469f42d7e2 @@ -0,0 +1 @@ +Subproject commit 60308b2e294514ab8f63b530c0a0d84fe1f0e23d diff --git a/.claude/worktrees/agent-ae3365cae695557d3 b/.claude/worktrees/agent-ae3365cae695557d3 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-ae3365cae695557d3 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ae38e5d8ac177ad07 b/.claude/worktrees/agent-ae38e5d8ac177ad07 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-ae38e5d8ac177ad07 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ae9e13f6071e1a8da b/.claude/worktrees/agent-ae9e13f6071e1a8da new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-ae9e13f6071e1a8da @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-aed0bc540b62160e9 b/.claude/worktrees/agent-aed0bc540b62160e9 new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-aed0bc540b62160e9 @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-afccdbae8548f4078 b/.claude/worktrees/agent-afccdbae8548f4078 new file mode 160000 index 0000000..d6b5ad0 --- /dev/null +++ b/.claude/worktrees/agent-afccdbae8548f4078 @@ -0,0 +1 @@ +Subproject commit d6b5ad089b5c7492de752f231dec65b8cc984662 diff --git a/.claude/worktrees/agent-afe061bd188822c8b b/.claude/worktrees/agent-afe061bd188822c8b new file mode 160000 index 0000000..bfd516a --- /dev/null +++ b/.claude/worktrees/agent-afe061bd188822c8b @@ -0,0 +1 @@ +Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/pom.xml b/pom.xml index 6ad6bdc..bdc4bf1 100644 --- a/pom.xml +++ b/pom.xml @@ -179,6 +179,14 @@ runtime + + com.ibrasoft + tcketmanage-core + 0.0.1-SNAPSHOT + + + + diff --git a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java index c9901a4..fb69799 100644 --- a/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java +++ b/src/main/java/com/ibrasoft/lensbridge/LensBridgeApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAsync +@EnableScheduling public class LensBridgeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java index 986f5b8..49a6aae 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.dto.board.response; +import com.fasterxml.jackson.databind.JsonNode; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import lombok.AllArgsConstructor; @@ -16,4 +17,5 @@ public class MusallahBoardPayload { private DeviceConfig deviceConfig; private List frames; + private JsonNode weather; } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java b/src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java new file mode 100644 index 0000000..d4df334 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/OpenWeatherService.java @@ -0,0 +1,81 @@ +package com.ibrasoft.lensbridge.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Thin proxy around the OpenWeatherMap "current weather" API. The backend holds + * the API key and forwards the upstream JSON response verbatim so clients (e.g. + * the Musallah board) never see the key. + * + *

Weather changes slowly and must never sit in the board's request critical + * path, so the upstream call runs on a background schedule (every ~10 min) and + * the latest result is held in memory. {@link #getCurrentWeather()} is a + * non-blocking field read that never fails — at worst it returns {@code null} + * (no key configured, or before the first successful fetch). + */ +@Service +@Slf4j +public class OpenWeatherService { + + private final RestClient restClient; + private final String apiKey; + private final String location; + private final String units; + + private volatile JsonNode currentWeather; + + public OpenWeatherService( + @Value("${openweather.api.url:https://api.openweathermap.org/data/2.5/weather}") String apiUrl, + @Value("${openweather.api.key:}") String apiKey, + @Value("${openweather.location:Mississauga,ON,CA}") String location, + @Value("${openweather.units:metric}") String units) { + this.restClient = RestClient.create(apiUrl); + this.apiKey = apiKey; + this.location = location; + this.units = units; + } + + /** Returns the most recently fetched weather, or {@code null} if none yet. */ + public JsonNode getCurrentWeather() { + return currentWeather; + } + + /** + * Refreshes the cached weather from OpenWeatherMap. Runs on a fixed delay + * (so a slow upstream call can't pile up overlapping requests) and swallows + * all failures — a transient outage just leaves the last good value in + * place rather than propagating to the board. + */ + @Scheduled(fixedDelayString = "${openweather.refresh-interval-ms:600000}") + void refreshWeather() { + if (apiKey == null || apiKey.isBlank()) { + log.warn("OpenWeather API key not configured; skipping weather refresh"); + return; + } + try { + String uri = UriComponentsBuilder.newInstance() + .queryParam("q", location) + .queryParam("units", units) + .queryParam("appid", apiKey) + .build() + .toUriString(); + JsonNode response = restClient.get() + .uri(uri) + .retrieve() + .body(JsonNode.class); + if (response != null) { + currentWeather = response; + log.debug("Refreshed weather for {}", location); + } + } catch (Exception e) { + log.warn("Failed to refresh weather for {}: {} (keeping last known value)", + location, e.getMessage()); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index c85a3f7..c333ee5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -10,6 +10,7 @@ import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.board.transformer.EventListFrameTransformer; import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; @@ -34,6 +35,7 @@ public class BoardPayloadAssembler { private final PosterFrameTransformer posterTransformer; private final EventListFrameTransformer eventListTransformer; private final WeeklyContentFrameTransformer weeklyContentTransformer; + private final OpenWeatherService openWeatherService; public MusallahBoardPayload assemble(UUID deviceId) { Device device = deviceRepository.findById(deviceId) @@ -51,6 +53,7 @@ public MusallahBoardPayload assemble(UUID deviceId) { return MusallahBoardPayload.builder() .deviceConfig(ctx.getConfig()) .frames(frames) + .weather(openWeatherService.getCurrentWeather()) .build(); } From 44f0adcb56a039c51e342b0553c588287afcaa1a Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 21 Jun 2026 23:09:08 -0400 Subject: [PATCH 29/52] Stop tracking .claude/worktrees --- .claude/worktrees/agent-a0079e514e7f7eef1 | 1 - .claude/worktrees/agent-a09e2540ed87a7bc2 | 1 - .claude/worktrees/agent-a0b277ea65c3bdb5a | 1 - .claude/worktrees/agent-a0f8461f1447b024d | 1 - .claude/worktrees/agent-a1b682a0b3fa2de24 | 1 - .claude/worktrees/agent-a1c5ba1c7fe4880e8 | 1 - .claude/worktrees/agent-a25e53d6d6c243536 | 1 - .claude/worktrees/agent-a2bfe8f89be0dd235 | 1 - .claude/worktrees/agent-a2c7bb6b3b5b29490 | 1 - .claude/worktrees/agent-a3c71433aeb464d2a | 1 - .claude/worktrees/agent-a3cfd09e71c1f5bfc | 1 - .claude/worktrees/agent-a3f7af3a31c5506b3 | 1 - .claude/worktrees/agent-a4032405dca8687a2 | 1 - .claude/worktrees/agent-a4621e55991a7d914 | 1 - .claude/worktrees/agent-a512c5e42a6c35f63 | 1 - .claude/worktrees/agent-a661b8945be2ef540 | 1 - .claude/worktrees/agent-a689c9982ade3a881 | 1 - .claude/worktrees/agent-a6c085bbee5fce37a | 1 - .claude/worktrees/agent-a7376fc72b8daa46b | 1 - .claude/worktrees/agent-a78e5f23dc6ca9f66 | 1 - .claude/worktrees/agent-a7bdb3501c7bace18 | 1 - .claude/worktrees/agent-a7c3e77e0220e1a41 | 1 - .claude/worktrees/agent-a7df6510a299b45ce | 1 - .claude/worktrees/agent-a895cecdf99f398f4 | 1 - .claude/worktrees/agent-a897cb6c6a43fa7cb | 1 - .claude/worktrees/agent-a8d8e69975ec63e27 | 1 - .claude/worktrees/agent-a9ec14a214df2c8ec | 1 - .claude/worktrees/agent-aae6b1aea0d80eef3 | 1 - .claude/worktrees/agent-ab45f26720d622356 | 1 - .claude/worktrees/agent-abe79206221eedf92 | 1 - .claude/worktrees/agent-ac402729bb2a4badf | 1 - .claude/worktrees/agent-ac5fbcfb55851b8f2 | 1 - .claude/worktrees/agent-acb44a169029ab34e | 1 - .claude/worktrees/agent-ae1fa9c469f42d7e2 | 1 - .claude/worktrees/agent-ae3365cae695557d3 | 1 - .claude/worktrees/agent-ae38e5d8ac177ad07 | 1 - .claude/worktrees/agent-ae9e13f6071e1a8da | 1 - .claude/worktrees/agent-aed0bc540b62160e9 | 1 - .claude/worktrees/agent-afccdbae8548f4078 | 1 - .claude/worktrees/agent-afe061bd188822c8b | 1 - .gitignore | 3 ++- 41 files changed, 2 insertions(+), 41 deletions(-) delete mode 160000 .claude/worktrees/agent-a0079e514e7f7eef1 delete mode 160000 .claude/worktrees/agent-a09e2540ed87a7bc2 delete mode 160000 .claude/worktrees/agent-a0b277ea65c3bdb5a delete mode 160000 .claude/worktrees/agent-a0f8461f1447b024d delete mode 160000 .claude/worktrees/agent-a1b682a0b3fa2de24 delete mode 160000 .claude/worktrees/agent-a1c5ba1c7fe4880e8 delete mode 160000 .claude/worktrees/agent-a25e53d6d6c243536 delete mode 160000 .claude/worktrees/agent-a2bfe8f89be0dd235 delete mode 160000 .claude/worktrees/agent-a2c7bb6b3b5b29490 delete mode 160000 .claude/worktrees/agent-a3c71433aeb464d2a delete mode 160000 .claude/worktrees/agent-a3cfd09e71c1f5bfc delete mode 160000 .claude/worktrees/agent-a3f7af3a31c5506b3 delete mode 160000 .claude/worktrees/agent-a4032405dca8687a2 delete mode 160000 .claude/worktrees/agent-a4621e55991a7d914 delete mode 160000 .claude/worktrees/agent-a512c5e42a6c35f63 delete mode 160000 .claude/worktrees/agent-a661b8945be2ef540 delete mode 160000 .claude/worktrees/agent-a689c9982ade3a881 delete mode 160000 .claude/worktrees/agent-a6c085bbee5fce37a delete mode 160000 .claude/worktrees/agent-a7376fc72b8daa46b delete mode 160000 .claude/worktrees/agent-a78e5f23dc6ca9f66 delete mode 160000 .claude/worktrees/agent-a7bdb3501c7bace18 delete mode 160000 .claude/worktrees/agent-a7c3e77e0220e1a41 delete mode 160000 .claude/worktrees/agent-a7df6510a299b45ce delete mode 160000 .claude/worktrees/agent-a895cecdf99f398f4 delete mode 160000 .claude/worktrees/agent-a897cb6c6a43fa7cb delete mode 160000 .claude/worktrees/agent-a8d8e69975ec63e27 delete mode 160000 .claude/worktrees/agent-a9ec14a214df2c8ec delete mode 160000 .claude/worktrees/agent-aae6b1aea0d80eef3 delete mode 160000 .claude/worktrees/agent-ab45f26720d622356 delete mode 160000 .claude/worktrees/agent-abe79206221eedf92 delete mode 160000 .claude/worktrees/agent-ac402729bb2a4badf delete mode 160000 .claude/worktrees/agent-ac5fbcfb55851b8f2 delete mode 160000 .claude/worktrees/agent-acb44a169029ab34e delete mode 160000 .claude/worktrees/agent-ae1fa9c469f42d7e2 delete mode 160000 .claude/worktrees/agent-ae3365cae695557d3 delete mode 160000 .claude/worktrees/agent-ae38e5d8ac177ad07 delete mode 160000 .claude/worktrees/agent-ae9e13f6071e1a8da delete mode 160000 .claude/worktrees/agent-aed0bc540b62160e9 delete mode 160000 .claude/worktrees/agent-afccdbae8548f4078 delete mode 160000 .claude/worktrees/agent-afe061bd188822c8b diff --git a/.claude/worktrees/agent-a0079e514e7f7eef1 b/.claude/worktrees/agent-a0079e514e7f7eef1 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a0079e514e7f7eef1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a09e2540ed87a7bc2 b/.claude/worktrees/agent-a09e2540ed87a7bc2 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a09e2540ed87a7bc2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a0b277ea65c3bdb5a b/.claude/worktrees/agent-a0b277ea65c3bdb5a deleted file mode 160000 index 5932344..0000000 --- a/.claude/worktrees/agent-a0b277ea65c3bdb5a +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5932344b90593746346f790c7a6e8388781a9cde diff --git a/.claude/worktrees/agent-a0f8461f1447b024d b/.claude/worktrees/agent-a0f8461f1447b024d deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a0f8461f1447b024d +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a1b682a0b3fa2de24 b/.claude/worktrees/agent-a1b682a0b3fa2de24 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a1b682a0b3fa2de24 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a1c5ba1c7fe4880e8 b/.claude/worktrees/agent-a1c5ba1c7fe4880e8 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a1c5ba1c7fe4880e8 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a25e53d6d6c243536 b/.claude/worktrees/agent-a25e53d6d6c243536 deleted file mode 160000 index 5932344..0000000 --- a/.claude/worktrees/agent-a25e53d6d6c243536 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5932344b90593746346f790c7a6e8388781a9cde diff --git a/.claude/worktrees/agent-a2bfe8f89be0dd235 b/.claude/worktrees/agent-a2bfe8f89be0dd235 deleted file mode 160000 index 3ed2956..0000000 --- a/.claude/worktrees/agent-a2bfe8f89be0dd235 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3ed295672228633149e517b59a00bf65f4f74b0a diff --git a/.claude/worktrees/agent-a2c7bb6b3b5b29490 b/.claude/worktrees/agent-a2c7bb6b3b5b29490 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a2c7bb6b3b5b29490 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a3c71433aeb464d2a b/.claude/worktrees/agent-a3c71433aeb464d2a deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a3c71433aeb464d2a +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a3cfd09e71c1f5bfc b/.claude/worktrees/agent-a3cfd09e71c1f5bfc deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a3cfd09e71c1f5bfc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a3f7af3a31c5506b3 b/.claude/worktrees/agent-a3f7af3a31c5506b3 deleted file mode 160000 index 5932344..0000000 --- a/.claude/worktrees/agent-a3f7af3a31c5506b3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5932344b90593746346f790c7a6e8388781a9cde diff --git a/.claude/worktrees/agent-a4032405dca8687a2 b/.claude/worktrees/agent-a4032405dca8687a2 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a4032405dca8687a2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a4621e55991a7d914 b/.claude/worktrees/agent-a4621e55991a7d914 deleted file mode 160000 index c2d984b..0000000 --- a/.claude/worktrees/agent-a4621e55991a7d914 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c2d984b2440cc24eb5f89c25b256a9cca3b2a0e4 diff --git a/.claude/worktrees/agent-a512c5e42a6c35f63 b/.claude/worktrees/agent-a512c5e42a6c35f63 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a512c5e42a6c35f63 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a661b8945be2ef540 b/.claude/worktrees/agent-a661b8945be2ef540 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a661b8945be2ef540 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a689c9982ade3a881 b/.claude/worktrees/agent-a689c9982ade3a881 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a689c9982ade3a881 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a6c085bbee5fce37a b/.claude/worktrees/agent-a6c085bbee5fce37a deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a6c085bbee5fce37a +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7376fc72b8daa46b b/.claude/worktrees/agent-a7376fc72b8daa46b deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a7376fc72b8daa46b +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a78e5f23dc6ca9f66 b/.claude/worktrees/agent-a78e5f23dc6ca9f66 deleted file mode 160000 index aa25cb3..0000000 --- a/.claude/worktrees/agent-a78e5f23dc6ca9f66 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aa25cb3e72216089303c6d540c412cdd3e0619ee diff --git a/.claude/worktrees/agent-a7bdb3501c7bace18 b/.claude/worktrees/agent-a7bdb3501c7bace18 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a7bdb3501c7bace18 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7c3e77e0220e1a41 b/.claude/worktrees/agent-a7c3e77e0220e1a41 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a7c3e77e0220e1a41 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a7df6510a299b45ce b/.claude/worktrees/agent-a7df6510a299b45ce deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a7df6510a299b45ce +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a895cecdf99f398f4 b/.claude/worktrees/agent-a895cecdf99f398f4 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a895cecdf99f398f4 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-a897cb6c6a43fa7cb b/.claude/worktrees/agent-a897cb6c6a43fa7cb deleted file mode 160000 index 00e1bc6..0000000 --- a/.claude/worktrees/agent-a897cb6c6a43fa7cb +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 00e1bc6ae7fc716b8e2b379cdaffe1278b441f5c diff --git a/.claude/worktrees/agent-a8d8e69975ec63e27 b/.claude/worktrees/agent-a8d8e69975ec63e27 deleted file mode 160000 index 4fb6862..0000000 --- a/.claude/worktrees/agent-a8d8e69975ec63e27 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4fb6862dc5f9af72c2dff72877d064d71e8d73ef diff --git a/.claude/worktrees/agent-a9ec14a214df2c8ec b/.claude/worktrees/agent-a9ec14a214df2c8ec deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-a9ec14a214df2c8ec +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-aae6b1aea0d80eef3 b/.claude/worktrees/agent-aae6b1aea0d80eef3 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-aae6b1aea0d80eef3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ab45f26720d622356 b/.claude/worktrees/agent-ab45f26720d622356 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-ab45f26720d622356 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-abe79206221eedf92 b/.claude/worktrees/agent-abe79206221eedf92 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-abe79206221eedf92 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ac402729bb2a4badf b/.claude/worktrees/agent-ac402729bb2a4badf deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-ac402729bb2a4badf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ac5fbcfb55851b8f2 b/.claude/worktrees/agent-ac5fbcfb55851b8f2 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-ac5fbcfb55851b8f2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-acb44a169029ab34e b/.claude/worktrees/agent-acb44a169029ab34e deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-acb44a169029ab34e +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ae1fa9c469f42d7e2 b/.claude/worktrees/agent-ae1fa9c469f42d7e2 deleted file mode 160000 index 60308b2..0000000 --- a/.claude/worktrees/agent-ae1fa9c469f42d7e2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 60308b2e294514ab8f63b530c0a0d84fe1f0e23d diff --git a/.claude/worktrees/agent-ae3365cae695557d3 b/.claude/worktrees/agent-ae3365cae695557d3 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-ae3365cae695557d3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ae38e5d8ac177ad07 b/.claude/worktrees/agent-ae38e5d8ac177ad07 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-ae38e5d8ac177ad07 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-ae9e13f6071e1a8da b/.claude/worktrees/agent-ae9e13f6071e1a8da deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-ae9e13f6071e1a8da +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-aed0bc540b62160e9 b/.claude/worktrees/agent-aed0bc540b62160e9 deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-aed0bc540b62160e9 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.claude/worktrees/agent-afccdbae8548f4078 b/.claude/worktrees/agent-afccdbae8548f4078 deleted file mode 160000 index d6b5ad0..0000000 --- a/.claude/worktrees/agent-afccdbae8548f4078 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d6b5ad089b5c7492de752f231dec65b8cc984662 diff --git a/.claude/worktrees/agent-afe061bd188822c8b b/.claude/worktrees/agent-afe061bd188822c8b deleted file mode 160000 index bfd516a..0000000 --- a/.claude/worktrees/agent-afe061bd188822c8b +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfd516a8ba72e2d3566b9a001f54042ad21bce4a diff --git a/.gitignore b/.gitignore index 85fe1a1..f41c965 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ lensbridge-dev.db .env.* !.env.example docker/nginx/certs/* -!docker/nginx/certs/.gitkeep \ No newline at end of file +!docker/nginx/certs/.gitkeep +.claude/worktrees/* From 96a190a4036f891f0e45561c200771056c848b14 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Fri, 31 Jul 2026 21:27:04 -0400 Subject: [PATCH 30/52] massive bugfix + refactor + a whole weeks worth of work I forgot to commit --- .../LensBridgeApplicationTests.java | 16 -- .../DeviceAdminControllerPermissionTest.java | 105 ----------- .../EventsControllerPermissionTest.java | 64 ------- .../GalleryControllerPermissionTest.java | 67 ------- ...MusallahBoardControllerPermissionTest.java | 118 ------------- .../SecurityFilterChainMatrixTest.java | 166 ------------------ 6 files changed, 536 deletions(-) delete mode 100644 src/test/java/com/ibrasoft/lensbridge/LensBridgeApplicationTests.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java diff --git a/src/test/java/com/ibrasoft/lensbridge/LensBridgeApplicationTests.java b/src/test/java/com/ibrasoft/lensbridge/LensBridgeApplicationTests.java deleted file mode 100644 index 2c3689f..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/LensBridgeApplicationTests.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ibrasoft.lensbridge; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@AutoConfigureMockMvc -class LensBridgeApplicationTests { - - @Test - void contextLoads() { - - } - -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java deleted file mode 100644 index 34e24dc..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerPermissionTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.ibrasoft.lensbridge.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; -import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; -import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; -import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.RequestPostProcessor; - -import java.util.UUID; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * Security-only integration tests for {@link DeviceAdminController}. Every endpoint is class-level - * {@code @PreAuthorize("hasRole('ROOT')")}; this verifies anonymous -> 401, USER/ADMIN -> 403, - * and ROOT -> not 401/403. CSRF is disabled, so no csrf() post-processor is needed. - */ -@WebMvcTest(controllers = DeviceAdminController.class) -@Import(MethodSecurityTestConfig.class) -class DeviceAdminControllerPermissionTest { - - private static final String BASE = "/api/admin/board/devices"; - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private EnrollmentTokenService enrollmentTokenService; - @MockitoBean - private DeviceRepository deviceRepository; - @MockitoBean - private DeviceCommandRepository commandRepository; - @MockitoBean - private CommandDispatcher commandDispatcher; - @MockitoBean - private ObjectMapper objectMapper; - @MockitoBean - private AgentSessionRegistry sessionRegistry; - - private static String id() { - return UUID.randomUUID().toString(); - } - - @Test - @WithAnonymousUser - void anonymousIsUnauthorizedForAllEndpoints() throws Exception { - mockMvc.perform(get(BASE)).andExpect(status().isUnauthorized()); - mockMvc.perform(get(BASE + "/" + id())).andExpect(status().isUnauthorized()); - mockMvc.perform(post(BASE + "/enrollment-tokens") - .contentType(MediaType.APPLICATION_JSON).content("{}")) - .andExpect(status().isUnauthorized()); - mockMvc.perform(post(BASE + "/" + id() + "/revoke")).andExpect(status().isUnauthorized()); - mockMvc.perform(post(BASE + "/" + id() + "/commands") - .contentType(MediaType.APPLICATION_JSON).content("{}")) - .andExpect(status().isUnauthorized()); - mockMvc.perform(get(BASE + "/" + id() + "/commands")).andExpect(status().isUnauthorized()); - } - - @ParameterizedTest - @CsvSource({"USER", "ADMIN"}) - @WithMockUser - void nonRootRolesAreForbidden(String role) throws Exception { - runAllEndpoints(role, true); - } - - @Test - @WithMockUser(roles = "ROOT") - void rootIsAuthorized() throws Exception { - runAllEndpoints("ROOT", false); - } - - private void runAllEndpoints(String role, boolean expectForbidden) throws Exception { - RequestPostProcessor user = SecurityMockMvcRequestPostProcessors.user("u").roles(role); - - check(expectForbidden, mockMvc.perform(get(BASE).with(user))); - check(expectForbidden, mockMvc.perform(get(BASE + "/" + id()).with(user))); - check(expectForbidden, mockMvc.perform(post(BASE + "/enrollment-tokens").with(user) - .contentType(MediaType.APPLICATION_JSON).content("{}"))); - check(expectForbidden, mockMvc.perform(post(BASE + "/" + id() + "/revoke").with(user))); - check(expectForbidden, mockMvc.perform(post(BASE + "/" + id() + "/commands").with(user) - .contentType(MediaType.APPLICATION_JSON).content("{}"))); - check(expectForbidden, mockMvc.perform(get(BASE + "/" + id() + "/commands").with(user))); - } - - private void check(boolean expectForbidden, ResultActions actions) throws Exception { - PermissionAssertions.expectAuthorizationOutcome(actions, expectForbidden); - } -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java deleted file mode 100644 index 1e027d5..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/EventsControllerPermissionTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.ibrasoft.lensbridge.controller; - -import com.ibrasoft.lensbridge.security.WebSecurityConfig; -import com.ibrasoft.lensbridge.security.jwt.JwtUtils; -import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; -import com.ibrasoft.lensbridge.service.EventsService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Collections; -import java.util.Optional; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * Verifies that every endpoint on {@link EventsController} is genuinely public - * (matched by the {@code /api/events/**} permitAll rule in {@link WebSecurityConfig}). - * A response that is NOT 401 and NOT 403 when called anonymously is a PASS. - */ -@WebMvcTest(EventsController.class) -@Import(WebSecurityConfig.class) -@TestPropertySource(properties = { - "frontend.baseurl=http://localhost:3000", - "musallahboard.baseurl=http://localhost:4000" -}) -class EventsControllerPermissionTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private EventsService eventsService; - - @MockitoBean - private UserDetailsServiceImpl userDetailsService; - - @MockitoBean - private JwtUtils jwtUtils; - - @Test - void getAllEvents_isPublic() throws Exception { - when(eventsService.getPublicVisibleEvents()).thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/events")) - .andExpect(status().isOk()); - } - - @Test - void getEventById_isPublic() throws Exception { - when(eventsService.getEventById(any(UUID.class))).thenReturn(Optional.empty()); - - mockMvc.perform(get("/api/events/{id}", UUID.randomUUID())) - .andExpect(status().isNotFound()); - } -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java deleted file mode 100644 index cd60bec..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/GalleryControllerPermissionTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.ibrasoft.lensbridge.controller; - -import com.ibrasoft.lensbridge.security.WebSecurityConfig; -import com.ibrasoft.lensbridge.security.jwt.JwtUtils; -import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; -import com.ibrasoft.lensbridge.service.GalleryService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.data.domain.PageImpl; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Collections; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * Verifies that every endpoint on {@link GalleryController} is genuinely public - * (matched by the {@code /api/gallery/**} permitAll rule in {@link WebSecurityConfig}). - * A response that is NOT 401 and NOT 403 when called anonymously is a PASS. - */ -@WebMvcTest(GalleryController.class) -@Import(WebSecurityConfig.class) -@TestPropertySource(properties = { - "frontend.baseurl=http://localhost:3000", - "musallahboard.baseurl=http://localhost:4000" -}) -class GalleryControllerPermissionTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private GalleryService galleryService; - - // Required to satisfy WebSecurityConfig wiring. - @MockitoBean - private UserDetailsServiceImpl userDetailsService; - - @MockitoBean - private JwtUtils jwtUtils; - - @Test - void getAllUploads_isPublic() throws Exception { - doReturn(new PageImpl<>(Collections.emptyList())) - .when(galleryService).getAllApprovedGalleryItems(any()); - - mockMvc.perform(get("/api/gallery")) - .andExpect(status().isOk()); - } - - @Test - void getGalleryByEvent_isPublic() throws Exception { - doReturn(new PageImpl<>(Collections.emptyList())) - .when(galleryService).getGalleryItemsByEvent(any(UUID.class), any()); - - mockMvc.perform(get("/api/gallery/event/{eventId}", UUID.randomUUID())) - .andExpect(status().isOk()); - } -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java deleted file mode 100644 index d19ca72..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/controller/MusallahBoardControllerPermissionTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.ibrasoft.lensbridge.controller; - -import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; -import com.ibrasoft.lensbridge.security.WebSecurityConfig; -import com.ibrasoft.lensbridge.security.jwt.JwtUtils; -import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; -import com.ibrasoft.lensbridge.service.BoardService; -import com.ibrasoft.lensbridge.service.PosterService; -import com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Collections; -import java.util.Optional; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * Verifies that every endpoint on {@link MusallahBoardController} is genuinely public - * (matched by the {@code /api/musallah/**} permitAll rule in {@link WebSecurityConfig}). - * A response that is NOT 401 and NOT 403 when called anonymously is a PASS. - */ -@WebMvcTest(MusallahBoardController.class) -@Import(WebSecurityConfig.class) -@TestPropertySource(properties = { - "frontend.baseurl=http://localhost:3000", - "musallahboard.baseurl=http://localhost:4000" -}) -class MusallahBoardControllerPermissionTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private PosterService posterService; - - @MockitoBean - private BoardService boardService; - - @MockitoBean - private BoardPayloadAssembler payloadAssembler; - - @MockitoBean - private UserDetailsServiceImpl userDetailsService; - - @MockitoBean - private JwtUtils jwtUtils; - - @Test - void getBoardConfig_isPublic() throws Exception { - when(boardService.getBoardConfig(any(UUID.class))).thenReturn(Optional.empty()); - - mockMvc.perform(get("/api/musallah/config").param("deviceId", UUID.randomUUID().toString())) - .andExpect(status().isNotFound()); - } - - @Test - void getCurrentWeeklyContent_isPublic() throws Exception { - when(boardService.getCurrentWeeklyContent()).thenReturn(Optional.empty()); - - mockMvc.perform(get("/api/musallah/weekly-content")) - .andExpect(status().isNotFound()); - } - - @Test - void getWeeklyContent_isPublic() throws Exception { - when(boardService.getWeeklyContent(anyInt(), anyInt())).thenReturn(Optional.empty()); - - mockMvc.perform(get("/api/musallah/weekly-content/{year}/{weekNumber}", 2026, 12)) - .andExpect(status().isNotFound()); - } - - @Test - void getActivePosterFrames_isPublic() throws Exception { - when(posterService.getActivePosterFrameDefinitions(any())).thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/musallah/posters").param("audience", "BOTH")) - .andExpect(status().isOk()); - } - - @Test - void getUpcomingEvents_isPublic() throws Exception { - when(boardService.getUpcomingEventsForAudience(any())).thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/musallah/events").param("audience", "BOTH")) - .andExpect(status().isOk()); - } - - @Test - void getEventsInRange_isPublic() throws Exception { - when(boardService.getEventsForAudienceInRange(any(), any(), any())) - .thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/musallah/events/range") - .param("audience", "BOTH") - .param("start", "2026-01-01T00:00:00Z") - .param("end", "2026-12-31T00:00:00Z")) - .andExpect(status().isOk()); - } - - @Test - void getBoardPayload_isPublic() throws Exception { - when(payloadAssembler.assemble(any(UUID.class))).thenReturn(new MusallahBoardPayload()); - - mockMvc.perform(get("/api/musallah/payload").param("deviceId", UUID.randomUUID().toString())) - .andExpect(status().isOk()); - } -} diff --git a/src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java b/src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java deleted file mode 100644 index cbb3780..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/security/SecurityFilterChainMatrixTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.ibrasoft.lensbridge.security; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.HttpMethod; -import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; - -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; - -/** - * End-to-end security matrix test that boots the FULL application context (default dev - * profile, SQLite) so it exercises the REAL {@link WebSecurityConfig} filter chain, - * {@code AuthTokenFilter}, {@code AuthEntryPointJwt} and method-level {@code @PreAuthorize} - * annotations together -- NOT a sliced {@code @WebMvcTest}. - * - *

The assertions intentionally only distinguish between the three security-relevant - * outcomes: - *

    - *
  • 401 -- anonymous access to a protected resource (entry point fired)
  • - *
  • 403 -- authenticated but lacking the required role (access denied)
  • - *
  • allowed -- request passed security; downstream 200/400/404/500 from - * missing test data is acceptable, we only assert it is NOT 401 and NOT 403
  • - *
- * - *

Note on swagger/openapi paths: {@code WebSecurityConfig} permits - * {@code /swagger-ui.html} explicitly and {@code /v3/api-docs/**} as a prefix. The bare - * {@code /v3/api-docs} path is not covered by the {@code /**} matcher, so the - * permitAll-reachable check uses {@code /v3/api-docs/swagger-config} which is covered. - */ -@SpringBootTest -@AutoConfigureMockMvc -class SecurityFilterChainMatrixTest { - - private static final int UNAUTHORIZED = 401; - private static final int FORBIDDEN = 403; - - @Autowired - private MockMvc mockMvc; - - private int status(HttpMethod method, String path) throws Exception { - MockHttpServletRequestBuilder builder = request(method, path); - if (method == HttpMethod.POST || method == HttpMethod.PUT || method == HttpMethod.PATCH) { - builder = builder.contentType("application/json").content("{}"); - } - MvcResult result = mockMvc.perform(builder).andReturn(); - return result.getResponse().getStatus(); - } - - // --- Public paths: anonymous access must NOT be rejected with 401 ----------------- - - static Stream publicPaths() { - return Stream.of( - Arguments.of(HttpMethod.GET, "/actuator/health"), - Arguments.of(HttpMethod.POST, "/api/auth/signin"), - Arguments.of(HttpMethod.GET, "/api/gallery/approved"), - Arguments.of(HttpMethod.GET, "/api/events"), - Arguments.of(HttpMethod.GET, "/api/musallah/config"), - Arguments.of(HttpMethod.POST, "/api/agent/enroll"), - Arguments.of(HttpMethod.GET, "/swagger-ui.html"), - Arguments.of(HttpMethod.GET, "/v3/api-docs/swagger-config")); - } - - @ParameterizedTest(name = "[{index}] {0} {1} reachable anonymously") - @MethodSource("publicPaths") - @WithAnonymousUser - @DisplayName("Public path prefixes are reachable anonymously (not 401)") - void publicPathsAreNotUnauthorized(HttpMethod method, String path) throws Exception { - assertThat(status(method, path)) - .as("%s %s should be publicly reachable (not 401)", method, path) - .isNotEqualTo(UNAUTHORIZED); - } - - // --- Protected paths: anonymous access must be 401 ------------------------------- - - static Stream protectedPaths() { - return Stream.of( - Arguments.of(HttpMethod.GET, "/api/user/profile"), - Arguments.of(HttpMethod.GET, "/api/upload/limits"), - Arguments.of(HttpMethod.GET, "/api/admin/uploads"), - Arguments.of(HttpMethod.GET, "/api/admin/board/configs")); - } - - @ParameterizedTest(name = "[{index}] {0} {1} -> 401 when anonymous") - @MethodSource("protectedPaths") - @WithAnonymousUser - @DisplayName("Protected paths return 401 when anonymous") - void protectedPathsReturnUnauthorizedWhenAnonymous(HttpMethod method, String path) throws Exception { - assertThat(status(method, path)) - .as("%s %s should require authentication", method, path) - .isEqualTo(UNAUTHORIZED); - } - - // --- Role: USER ------------------------------------------------------------------ - - @ParameterizedTest(name = "[{index}] USER {0} {1} -> {2}") - @MethodSource("userRoleCases") - @WithMockUser(roles = "USER") - @DisplayName("USER role: own profile allowed, admin endpoints forbidden") - void userRoleMatrix(HttpMethod method, String path, boolean expectForbidden) throws Exception { - int code = status(method, path); - if (expectForbidden) { - assertThat(code).as("%s %s should be 403 for USER", method, path).isEqualTo(FORBIDDEN); - } else { - assertThat(code).as("%s %s should be allowed for USER (not 401/403)", method, path) - .isNotEqualTo(UNAUTHORIZED).isNotEqualTo(FORBIDDEN); - } - } - - static Stream userRoleCases() { - return Stream.of( - Arguments.of(HttpMethod.GET, "/api/user/profile", false), - Arguments.of(HttpMethod.GET, "/api/admin/uploads", true), - Arguments.of(HttpMethod.GET, "/api/admin/board/configs", true)); - } - - // --- Role: ADMIN ----------------------------------------------------------------- - - @ParameterizedTest(name = "[{index}] ADMIN {0} {1} -> {2}") - @MethodSource("adminRoleCases") - @WithMockUser(roles = "ADMIN") - @DisplayName("ADMIN role: admin uploads allowed, board configs forbidden (ROOT only)") - void adminRoleMatrix(HttpMethod method, String path, boolean expectForbidden) throws Exception { - int code = status(method, path); - if (expectForbidden) { - assertThat(code).as("%s %s should be 403 for ADMIN", method, path).isEqualTo(FORBIDDEN); - } else { - assertThat(code).as("%s %s should be allowed for ADMIN (not 401/403)", method, path) - .isNotEqualTo(UNAUTHORIZED).isNotEqualTo(FORBIDDEN); - } - } - - static Stream adminRoleCases() { - return Stream.of( - Arguments.of(HttpMethod.GET, "/api/admin/uploads", false), - Arguments.of(HttpMethod.GET, "/api/admin/board/configs", true)); - } - - // --- Role: ROOT ------------------------------------------------------------------ - - @ParameterizedTest(name = "[{index}] ROOT {0} {1} allowed") - @MethodSource("rootRoleCases") - @WithMockUser(roles = "ROOT") - @DisplayName("ROOT role: board configs allowed (not 401/403)") - void rootRoleMatrix(HttpMethod method, String path) throws Exception { - assertThat(status(method, path)) - .as("%s %s should be allowed for ROOT (not 401/403)", method, path) - .isNotEqualTo(UNAUTHORIZED).isNotEqualTo(FORBIDDEN); - } - - static Stream rootRoleCases() { - return Stream.of( - Arguments.of(HttpMethod.GET, "/api/admin/board/configs")); - } -} From c7f3d65fe16461bd89e632cad7eebb0327b268fe Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Fri, 31 Jul 2026 21:27:14 -0400 Subject: [PATCH 31/52] forgot to commit hehe --- .../config/MusallahBoardConfig.java | 22 ++ .../lensbridge/config/WebSocketConfig.java | 11 +- .../controller/AgentEnrollmentController.java | 2 +- .../controller/BoardAdminController.java | 14 +- .../dto/board/agent/HeartbeatFrame.java | 14 +- .../board/request/IssueCommandRequest.java | 9 +- .../request/UpdateBoardConfigRequest.java | 7 - .../board/response/CommandIssuedResponse.java | 3 +- .../dto/board/response/CommandView.java | 2 + .../dto/board/response/DisplayConfig.java | 13 - .../dto/board/response/frames/EventView.java | 20 +- .../handler/BoardStreamHandler.java | 200 ++++++++++++++++ .../lensbridge/handler/SignboardHandler.java | 43 ---- .../lensbridge/model/board/DeviceCommand.java | 5 +- .../model/board/DeviceTelemetry.java | 9 +- .../model/board/embedded/DeviceConfig.java | 3 - .../model/board/frames/FrameConfig.java | 5 +- .../board/frames/NextPrayerFrameConfig.java | 19 -- .../sql/DeviceCommandRepository.java | 12 + .../lensbridge/service/BoardService.java | 26 +- .../lensbridge/service/PosterService.java | 6 + .../service/agent/CommandDispatcher.java | 50 +++- .../service/agent/DeviceCommandReaper.java | 83 +++++++ .../agent/DeviceEnrollmentService.java | 3 - .../service/agent/HeartbeatService.java | 16 +- .../agent/events/DeviceEventPublisher.java | 10 + .../service/board/BoardContext.java | 64 +++-- .../service/board/BoardPayloadAssembler.java | 26 +- .../DailyScheduleFrameTransformer.java | 12 +- .../EventListFrameTransformer.java | 12 +- .../resources/application.properties.example | 13 + .../handler/AgentWebSocketHandlerTest.java | 28 +++ .../handler/BoardStreamHandlerTest.java | 225 ++++++++++++++++++ .../lensbridge/service/BoardServiceTest.java | 7 +- .../lensbridge/service/PosterServiceTest.java | 3 + .../service/agent/CommandDispatcherTest.java | 70 +++++- .../agent/DeviceCommandReaperTest.java | 113 +++++++++ .../service/agent/HeartbeatServiceTest.java | 2 +- .../service/board/BoardContextTest.java | 144 ++++++----- 39 files changed, 1076 insertions(+), 250 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaperTest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java new file mode 100644 index 0000000..fd6020e --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/MusallahBoardConfig.java @@ -0,0 +1,22 @@ +package com.ibrasoft.lensbridge.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.ZoneId; + +@Configuration +public class MusallahBoardConfig { + + /** + * Zone used for a board whose device config has no timezone, or an unparseable one. + * Designed to be decoupled from the JVM's default timezone to avoid issues with container images + * that don't set it. + */ + @Bean + public ZoneId boardDefaultZone( + @Value("${musallahboard.defaultTimezone:America/Toronto}") String timezone) { + return ZoneId.of(timezone); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java index faa3200..09520a7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebSocketConfig.java @@ -9,7 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import com.ibrasoft.lensbridge.handler.AgentWebSocketHandler; -import com.ibrasoft.lensbridge.handler.SignboardHandler; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; @@ -17,7 +17,7 @@ @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { - private final SignboardHandler signboardHandler; + private final BoardStreamHandler boardStreamHandler; private final AgentWebSocketHandler agentWebSocketHandler; @Value("${frontend.baseurl}") @@ -26,15 +26,16 @@ public class WebSocketConfig implements WebSocketConfigurer { @Value("${musallahboard.baseurl}") String musallahBoardBaseUrl; - public WebSocketConfig(SignboardHandler signboardHandler, + public WebSocketConfig(BoardStreamHandler boardStreamHandler, AgentWebSocketHandler agentWebSocketHandler) { - this.signboardHandler = signboardHandler; + this.boardStreamHandler = boardStreamHandler; this.agentWebSocketHandler = agentWebSocketHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(signboardHandler, "/api/refresh-musallahboard") + // Path is the deployed kiosk's; boards scope themselves with ?deviceId=. + registry.addHandler(boardStreamHandler, "/api/refresh-musallahboard") .setAllowedOrigins(frontendBaseUrl, musallahBoardBaseUrl); // Agents authenticate per-frame inside the channel; the WS upgrade itself is open. diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java index 404066e..58b7fe4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -31,7 +31,7 @@ public class AgentEnrollmentController { private final DeviceEnrollmentService enrollmentService; - @Value("${musallahboard.agent.websocketUrl:ws://10.0.0.75/api/agent/ws}") + @Value("${musallahboard.agent.websocketUrl}") private String websocketUrl; @PostMapping("/enroll") diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 0bcd62c..efb17c7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -7,7 +7,7 @@ import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; -import com.ibrasoft.lensbridge.handler.SignboardHandler; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.BoardEvent; @@ -23,7 +23,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -52,8 +51,7 @@ public class BoardAdminController { private final PosterService posterService; private final BoardService boardService; private final AdminAuditService auditService; - @Autowired - private SignboardHandler signboardHandler; + private final BoardStreamHandler boardStream; // ==================== Board Config Endpoints ==================== @@ -285,13 +283,11 @@ public ResponseEntity deleteEvent( } @PostMapping("/refresh") - public ResponseEntity refreshSignboard() { - signboardHandler.sendRefreshCommand(); - return ResponseEntity.ok("Refresh command sent to MusallahBoard instances"); + public ResponseEntity refreshSignboard() { + boardStream.refreshAll(); + return ResponseEntity.ok(new MessageResponse("Refresh sent to all MusallahBoard instances")); } - // ==================== Helper Methods ==================== - private String getCurrentUserEmail() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication != null ? authentication.getName() : "unknown"; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java index 83d58f7..ccdc0f3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java @@ -1,11 +1,11 @@ package com.ibrasoft.lensbridge.dto.board.agent; +import com.fasterxml.jackson.annotation.JsonAlias; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import java.util.List; -import java.util.UUID; @Getter @Setter @@ -26,6 +26,16 @@ public static class Telemetry { private Boolean kioskAlive; private List ipv4; private String wifiSsid; - private UUID displayedFrameId; + + /** + * Agent build currently running. Reported on every heartbeat rather than only at + * enrollment, so {@code Device.agentVersion} tracks what is actually deployed after + * a binary push instead of whatever first enrolled. + */ + private String agentVersion; + + // JSON alias for back-compat. will be removed in a future release. + @JsonAlias("displayedFrameId") + private String displayedFrameKey; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java index 699edcd..ba6f718 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueCommandRequest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; /** * Admin request to issue a command at a single device. @@ -10,11 +11,17 @@ * {@code kind} is the polymorphic discriminator (must match a registered * {@link com.ibrasoft.lensbridge.model.board.commands.CommandPayload} subtype). * {@code payload} is the kind-specific JSON body (may be empty for parameterless commands). + *

+ * {@code deadlineMs} bounds execution once the agent starts. {@code ttlSeconds} bounds how + * long the command stays deliverable while the device is offline. Keep it short for + * anything disruptive (a reboot that fires a day late is a bug, not a feature ;) ). Both fall + * back to server defaults when null. */ public record IssueCommandRequest( @NotBlank @Pattern(regexp = "[a-z]+\\.[a-z_]+", message = "kind must be in dotted lowercase form, e.g. chrome.reload") String kind, JsonNode payload, - Integer deadlineMs + @Positive Integer deadlineMs, + @Positive Integer ttlSeconds ) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java index 94598b4..6ae4de1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -1,20 +1,13 @@ package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Location; -import jakarta.validation.constraints.Min; import lombok.*; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateBoardConfigRequest { private Location location; - @Min(value = 1000, message = "Poster cycle interval must be at least 1000ms") - private Integer posterCycleIntervalMs; - @Min(value = 0) - private Integer refreshAfterIshaMinutes; private Boolean darkModeAfterIsha; - @Min(value = 0) - private Integer darkModeAfterMaghribMinutes; private Boolean enableScrollingMessage; private List scrollingMessages; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java index c11d2ba..6f01779 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandIssuedResponse.java @@ -10,5 +10,6 @@ public record CommandIssuedResponse( UUID deviceId, String kind, DeviceCommandStatus status, - Instant issuedAt + Instant issuedAt, + Instant expiresAt ) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java index 284cb36..de7cd51 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/CommandView.java @@ -18,6 +18,7 @@ public record CommandView( Integer deadlineMs, DeviceCommandStatus status, Instant issuedAt, + Instant expiresAt, Instant deliveredAt, Instant ackedAt, Instant startedAt, @@ -35,6 +36,7 @@ public static CommandView of(DeviceCommand cmd, ObjectMapper mapper) { cmd.getDeadlineMs(), cmd.getStatus(), cmd.getIssuedAt(), + cmd.getExpiresAt(), cmd.getDeliveredAt(), cmd.getAckedAt(), cmd.getStartedAt(), diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java deleted file mode 100644 index 56558b9..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DisplayConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ibrasoft.lensbridge.dto.board.response; - -import lombok.*; - -@Data @Builder @NoArgsConstructor @AllArgsConstructor -public class DisplayConfig { - private int posterCycleIntervalMs; - private int refreshAfterIshaMinutes; - private boolean darkModeAfterIsha; - private int darkModeAfterIshaMinutes; - private boolean enableScrollingMessage; - private java.util.List scrollingMessages; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java index 8472142..ff87ceb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/EventView.java @@ -1,10 +1,17 @@ package com.ibrasoft.lensbridge.dto.board.response.frames; +import com.ibrasoft.lensbridge.model.board.BoardEvent; import lombok.*; import java.time.Instant; -@Data @Builder @NoArgsConstructor @AllArgsConstructor +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +/** + * Read-only view of a {@link BoardEvent} for REST responses. + */ public class EventView { private String name; private String description; @@ -12,4 +19,15 @@ public class EventView { private Instant startTime; private Instant endTime; private Boolean allDay; + + public static EventView of(BoardEvent event) { + return EventView.builder() + .name(event.getName()) + .description(event.getDescription()) + .location(event.getLocation()) + .startTime(event.getStartTime()) + .endTime(event.getEndTime()) + .allDay(event.getAllDay()) + .build(); + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java new file mode 100644 index 0000000..c69269b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/handler/BoardStreamHandler.java @@ -0,0 +1,200 @@ +package com.ibrasoft.lensbridge.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Push channel for kiosk pages: tells a board its content or config changed so it can + * refetch without waiting out its ten-minute poll. + *

+ * Every board on this channel is an enrolled device. A connection must name a known, + * unrevoked {@code ?deviceId=} or it is closed on the spot — there is no anonymous + * or pre-enrollment state here. A board that has not been enrolled has no business on the + * stream: it has no payload to refetch and nothing can be addressed to it. + *

+ */ +@Component +@RequiredArgsConstructor +@Slf4j +public class BoardStreamHandler extends TextWebSocketHandler { + + // Should probably be enums but this is sufficient for now + static final CloseStatus CLOSE_NO_DEVICE = new CloseStatus(4001, "device_id_required"); + static final CloseStatus CLOSE_UNKNOWN_DEVICE = new CloseStatus(4002, "unknown_device"); + static final CloseStatus CLOSE_REVOKED = new CloseStatus(4003, "device_revoked"); + static final CloseStatus CLOSE_SUPERSEDED = CloseStatus.NORMAL.withReason("superseded"); + + /** Session attribute holding the device a connection was accepted as. */ + private static final String DEVICE_ATTR = "board.deviceId"; + + private final ObjectMapper objectMapper; + private final DeviceRepository deviceRepository; + + /** + * Live boards keyed by device. One session per device: a reloading kiosk can briefly + * open its replacement before the old socket finishes closing, so the newcomer wins + * and the stale one is closed rather than left to accumulate. + */ + private final Map sessions = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(@NonNull WebSocketSession session) { + UUID deviceId = parseDeviceId(session); + if (deviceId == null) { + close(session, CLOSE_NO_DEVICE); + return; + } + + Optional device = deviceRepository.findById(deviceId); + if (device.isEmpty()) { + log.warn("Board stream session={} named unknown device {}", session.getId(), deviceId); + close(session, CLOSE_UNKNOWN_DEVICE); + return; + } + if (device.get().getRevokedAt() != null) { + log.warn("Board stream session={} is a revoked device {}", session.getId(), deviceId); + close(session, CLOSE_REVOKED); + return; + } + + session.getAttributes().put(DEVICE_ATTR, deviceId); + WebSocketSession previous = sessions.put(deviceId, session); + if (previous != null && previous != session) { + log.info("Board stream device {} superseded session {} with {}", + deviceId, previous.getId(), session.getId()); + close(previous, CLOSE_SUPERSEDED); + } + log.info("Board stream connected: session={} device={}", session.getId(), deviceId); + } + + @Override + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) { + UUID deviceId = (UUID) session.getAttributes().get(DEVICE_ATTR); + if (deviceId == null) return; // rejected before it was ever registered + + // Two-arg remove: a superseded session closing later must not evict the + // replacement that has already taken its place. + sessions.remove(deviceId, session); + log.info("Board stream disconnected: session={} device={} status={}", + session.getId(), deviceId, status); + } + + /** Content every board shares — posters, events, weekly content. */ + public void contentChanged(String reason) { + push(reason, null); + } + + /** Config for one board. */ + public void configChanged(UUID deviceId) { + push("config", deviceId); + } + + /** Operator-triggered "refresh everything", kept as a manual escape hatch. */ + public void refreshAll() { + push("manual-refresh", null); + } + + /** + * Push a message to every connected board, or to a single one. + * + * @param reason why the board should refetch. Carried on the wire for logging and for + * a frontend that wants to react selectively. + * @param target device to notify, or null to reach every board. + */ + private void push(String reason, UUID target) { + String json = serialize(reason, target); + if (json == null) return; + + if (target != null) { + WebSocketSession session = sessions.get(target); + if (session == null) { + log.debug("Board stream reason={} device={} not connected — it will pick the change up on its next poll", + reason, target); + return; + } + if (send(target, session, json)) { + log.debug("Board stream push reason={} device={}", reason, target); + } + return; + } + + int sent = 0; + for (Map.Entry entry : sessions.entrySet()) { + if (send(entry.getKey(), entry.getValue(), json)) sent++; + } + log.debug("Board stream broadcast reason={} boards={}", reason, sent); + } + + /** @return true when the frame was written; drops the session if it has gone away. */ + private boolean send(UUID deviceId, WebSocketSession session, String json) { + if (!session.isOpen()) { + sessions.remove(deviceId, session); + return false; + } + try { + session.sendMessage(new TextMessage(json)); + return true; + } catch (Exception e) { + log.warn("Board stream send failed: session={} device={} err={}", + session.getId(), deviceId, e.getMessage()); + return false; + } + } + + private String serialize(String reason, UUID target) { + Map body = new LinkedHashMap<>(); + body.put("type", "refresh"); + body.put("reason", reason); + body.put("deviceId", target); + // Pre-formatted rather than a raw Instant: a mapper without JavaTimeModule throws on + // one, and the only symptom would be pushes silently going nowhere. + body.put("at", Instant.now().toString()); + try { + return objectMapper.writeValueAsString(body); + } catch (Exception e) { + log.error("Board stream message could not be serialized", e); + return null; + } + } + + private static UUID parseDeviceId(WebSocketSession session) { + String query = session.getUri() == null ? null : session.getUri().getQuery(); + if (query == null) return null; + for (String param : query.split("&")) { + if (!param.startsWith("deviceId=")) continue; + String raw = param.substring("deviceId=".length()); + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException e) { + log.warn("Board stream session={} sent a malformed deviceId: {}", session.getId(), raw); + return null; + } + } + return null; + } + + private static void close(WebSocketSession session, CloseStatus status) { + try { + if (session.isOpen()) session.close(status); + } catch (Exception e) { + log.debug("Board stream close failed: session={} err={}", session.getId(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java b/src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java deleted file mode 100644 index 122ef3a..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/handler/SignboardHandler.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.ibrasoft.lensbridge.handler; - -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.handler.TextWebSocketHandler; - -import java.io.IOException; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -@Component -public class SignboardHandler extends TextWebSocketHandler { - - private final Set sessions = ConcurrentHashMap.newKeySet(); - - @Override - public void afterConnectionEstablished(WebSocketSession session) { - sessions.add(session); - System.out.println("Signboard connected: " + session.getId()); - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - sessions.remove(session); - System.out.println("Signboard disconnected: " + session.getId()); - } - - public void sendRefreshCommand() { - sessions.removeIf(session -> !session.isOpen()); - sessions.forEach(session -> { - if (session.isOpen()) { - try { - session.sendMessage(new TextMessage("REFRESH")); - System.out.println("Sent REFRESH to session: " + session.getId()); - } catch (IOException e) { - System.err.println("Failed to send to session " + session.getId() + ": " + e.getMessage()); - } - } - }); - } -} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java index ead6209..9a32127 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceCommand.java @@ -44,9 +44,12 @@ public class DeviceCommand { @Column(nullable = false) private String issuedBy; - /** Maximum execution time as supplied by the issuer; null = server default. */ + /** Maximum execution time once the agent picks the command up; null = server default. */ private Integer deadlineMs; + @Column(name = "expires_at") + private Instant expiresAt; + @Enumerated(EnumType.STRING) @Column(nullable = false) private DeviceCommandStatus status; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java index 395dd42..8e28af9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/DeviceTelemetry.java @@ -8,7 +8,8 @@ /** * Append-only telemetry sample emitted by an agent over its WebSocket session. - * Retention is bounded by a periodic prune (default 30 days). + *

+ * Currently unbounded — one row per heartbeat, forever. A retention prune is still owed. */ @Entity @Table(name = "device_telemetry", indexes = { @@ -21,6 +22,8 @@ @AllArgsConstructor public class DeviceTelemetry { + public static final int MAX_FRAME_KEY_LENGTH = 64; + @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @@ -46,6 +49,6 @@ public class DeviceTelemetry { private String wifiSsid; - /** UUID of the FrameDefinition currently on screen, if known. */ - private UUID displayedFrameId; + @Column(name = "displayed_frame_key", length = MAX_FRAME_KEY_LENGTH) + private String displayedFrameKey; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java index 6795b74..3ee81b7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -24,10 +24,7 @@ public class DeviceConfig { @Embedded private Location location; - private int posterCycleIntervalMs; - private int refreshAfterIshaMinutes; private boolean darkModeAfterIsha; - private int darkModeAfterMaghribMinutes; private boolean enableScrollingMessage; @ElementCollection(fetch = FetchType.EAGER) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java index bb441d0..867a880 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java @@ -6,6 +6,10 @@ /** * Base class for frame-specific configuration. * Uses Jackson annotations for polymorphic serialization. + *

+ * {@link FrameType#NEXT_PRAYER} deliberately has no subtype here: the backend holds no + * prayer-time source, so the board computes that frame itself from + * {@code deviceConfig.location}. */ @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -16,7 +20,6 @@ @JsonSubTypes.Type(value = PosterFrameConfig.class, name = "poster"), @JsonSubTypes.Type(value = EventListFrameConfig.class, name = "event_list"), @JsonSubTypes.Type(value = DailyScheduleFrameConfig.class, name = "daily_schedule"), - @JsonSubTypes.Type(value = NextPrayerFrameConfig.class, name = "next_prayer"), @JsonSubTypes.Type(value = JummahFrameConfig.class, name = "jummah"), @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote") }) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java deleted file mode 100644 index 0894f06..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class NextPrayerFrameConfig extends FrameConfig { - - private String locationCity; - private String timezone; - private String calculationMethod; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java index 9a79aa7..c0624b3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/DeviceCommandRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.Instant; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -16,4 +18,14 @@ public interface DeviceCommandRepository extends JpaRepository findTop50ByDeviceIdOrderByIssuedAtDesc(UUID deviceId); + + /** Commands that outlived their delivery window — reaper input. */ + List findByStatusAndExpiresAtBefore(DeviceCommandStatus status, Instant cutoff); + + /** + * In-flight commands delivered before {@code cutoff}. A coarse filter: the reaper still + * checks each row against its own {@code deadlineMs} before declaring it timed out. + */ + List findByStatusInAndDeliveredAtBefore( + Collection statuses, Instant cutoff); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index f82195d..610e885 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -6,6 +6,7 @@ import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.board.*; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.repository.sql.BoardConfigRepository; @@ -35,6 +36,7 @@ public class BoardService { private final DeviceRepository deviceRepository; private final BoardEventRepository boardEventRepository; private final WeeklyContentRepository weeklyContentRepository; + private final BoardStreamHandler boardStream; // ==================== Board Config ==================== @@ -57,20 +59,19 @@ public DeviceConfig saveBoardConfig(UUID deviceId, DeviceConfig deviceConfig) { deviceConfig.setDevice(device); DeviceConfig saved = boardConfigRepository.save(deviceConfig); log.info("Saved board config for device: {}", deviceId); + boardStream.configChanged(deviceId); return saved; } public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest request) { DeviceConfig existing = getBoardConfigOrThrow(deviceId); Patch.apply(request.getLocation(), existing::setLocation); - Patch.apply(request.getPosterCycleIntervalMs(), existing::setPosterCycleIntervalMs); - Patch.apply(request.getRefreshAfterIshaMinutes(), existing::setRefreshAfterIshaMinutes); Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); - Patch.apply(request.getDarkModeAfterMaghribMinutes(), existing::setDarkModeAfterMaghribMinutes); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); DeviceConfig saved = boardConfigRepository.save(existing); log.info("Updated board config for device: {}", deviceId); + boardStream.configChanged(deviceId); return saved; } @@ -95,9 +96,19 @@ public WeeklyContent getWeeklyContentOrThrow(int year, int weekNumber) { ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); } + /** + * @param today the board's local date. Callers with a device in hand must pass its date, + * not the server's — near midnight the two disagree and the board would + * show the wrong week's jummah times. + */ + public Optional getWeeklyContentFor(LocalDate today) { + WeekId week = WeekId.fromDate(today); + return weeklyContentRepository.findByYearAndWeekNumber(week.getYear(), week.getWeekNumber()); + } + + /** Current week in the server's zone. For callers with no device context. */ public Optional getCurrentWeeklyContent() { - WeekId current = WeekId.fromDate(LocalDate.now()); - return weeklyContentRepository.findByYearAndWeekNumber(current.getYear(), current.getWeekNumber()); + return getWeeklyContentFor(LocalDate.now()); } public List getWeeklyContentByYear(int year) { @@ -141,6 +152,7 @@ public WeeklyContent saveWeeklyContent(int year, int weekNumber, WeeklyContentRe WeeklyContent saved = weeklyContentRepository.save(content); log.info("Saved weekly content for week {} of {}", weekNumber, year); + boardStream.contentChanged("weekly-content"); return saved; } @@ -151,6 +163,7 @@ public void deleteWeeklyContent(int year, int weekNumber) { ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); weeklyContentRepository.delete(content); log.info("Deleted weekly content for week {} of {}", weekNumber, year); + boardStream.contentChanged("weekly-content"); } // ==================== Events ==================== @@ -190,6 +203,7 @@ public BoardEvent createEvent(CreateCalendarEventRequest request) { .build(); BoardEvent saved = boardEventRepository.save(boardEvent); log.info("Created event: id={}, name={}", saved.getId(), saved.getName()); + boardStream.contentChanged("events"); return saved; } @@ -204,6 +218,7 @@ public BoardEvent updateEvent(UUID eventId, UpdateCalendarEventRequest request) Patch.apply(request.getAudience(), existing::setAudience); BoardEvent saved = boardEventRepository.save(existing); log.info("Updated event: id={}", eventId); + boardStream.contentChanged("events"); return saved; } @@ -211,5 +226,6 @@ public void deleteEvent(UUID eventId) { BoardEvent boardEvent = getEventById(eventId); boardEventRepository.delete(boardEvent); log.info("Deleted event: id={}", eventId); + boardStream.contentChanged("events"); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index 6c9ca18..40f6646 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -4,6 +4,7 @@ import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; import java.time.Instant; @@ -31,6 +32,7 @@ public class PosterService { private final PosterRepository posterRepository; private final R2StorageService r2StorageService; private final PosterFrameTransformer posterFrameTransformer; + private final BoardStreamHandler boardStream; @Value("${cloudflare.r2.public-url}") private String publicUrl; @@ -108,6 +110,7 @@ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) poster = posterRepository.save(poster); log.info("Created poster: id={}, title={}", poster.getId(), poster.getTitle()); + boardStream.contentChanged("posters"); return poster; } @@ -132,6 +135,7 @@ public Poster updatePoster(UUID posterId, UpdatePosterRequest request) { poster = posterRepository.save(poster); log.info("Updated poster: id={}", posterId); + boardStream.contentChanged("posters"); return poster; } @@ -173,6 +177,7 @@ public Poster updatePosterImage(UUID posterId, MultipartFile imageFile) { poster.setImage(publicUrl + "/" + objectKey); poster = posterRepository.save(poster); log.info("Updated poster image: id={}", posterId); + boardStream.contentChanged("posters"); return poster; } @@ -198,6 +203,7 @@ public void deletePoster(UUID posterId) { posterRepository.delete(poster); log.info("Deleted poster: id={}", posterId); + boardStream.contentChanged("posters"); } // ==================== Musallah Board Methods ==================== diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java index ad5cd95..50f593f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java @@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -50,6 +51,13 @@ public class CommandDispatcher { private static final int DEFAULT_DEADLINE_MS = 30_000; + /** + * How long an undelivered command stays deliverable. Long enough to survive a kiosk + * restart or a brief WiFi drop, short enough that nothing surprising fires after an + * outage. Overridable per command via {@code ttlSeconds}. + */ + private static final Duration DEFAULT_TTL = Duration.ofMinutes(5); + /** Discriminator strings registered on {@link CommandPayload}. Updated when adding a command kind. */ private static final Set KNOWN_KINDS = Set.of( "chrome.reload", @@ -86,6 +94,8 @@ public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandR } int deadline = request.deadlineMs() == null ? DEFAULT_DEADLINE_MS : request.deadlineMs(); + Duration ttl = request.ttlSeconds() == null ? DEFAULT_TTL : Duration.ofSeconds(request.ttlSeconds()); + Instant issuedAt = Instant.now(); DeviceCommand cmd = commandRepository.save(DeviceCommand.builder() .deviceId(deviceId) @@ -93,6 +103,8 @@ public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandR .payloadJson(payloadJson) .issuedBy(issuedBy) .deadlineMs(deadline) + .issuedAt(issuedAt) + .expiresAt(issuedAt.plus(ttl)) .status(DeviceCommandStatus.PENDING) .build()); @@ -104,19 +116,49 @@ public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandR cmd.getId(), cmd.getKind(), deviceId, issuedBy, cmd.getStatus()); return new CommandIssuedResponse( - cmd.getId(), cmd.getDeviceId(), cmd.getKind(), cmd.getStatus(), cmd.getIssuedAt()); + cmd.getId(), cmd.getDeviceId(), cmd.getKind(), cmd.getStatus(), + cmd.getIssuedAt(), cmd.getExpiresAt()); } - /** Delivers all PENDING commands for the device to the freshly-authenticated session. */ + /** + * Delivers PENDING commands for the device to the freshly-authenticated session, + * oldest first. Anything past its {@code expiresAt} is retired rather than delivered — + * a device returning from a long outage must not replay a queue of stale commands. + */ @Transactional public void flushPending(UUID deviceId, AgentSession session) { List pending = commandRepository .findByDeviceIdAndStatusOrderByIssuedAtAsc(deviceId, DeviceCommandStatus.PENDING); if (pending.isEmpty()) return; - log.info("Flushing {} pending command(s) to device {}", pending.size(), deviceId); + + Instant now = Instant.now(); + int delivered = 0; + int expired = 0; for (DeviceCommand cmd : pending) { - deliverTo(cmd, session); + if (isExpired(cmd, now)) { + expire(cmd, now); + expired++; + } else { + deliverTo(cmd, session); + delivered++; + } } + log.info("Flushed {} pending command(s) to device {} ({} expired)", delivered, deviceId, expired); + } + + /** Retires a command that outlived its delivery window. */ + void expire(DeviceCommand cmd, Instant now) { + cmd.setStatus(DeviceCommandStatus.EXPIRED); + cmd.setFinishedAt(now); + cmd.setErrorMessage("Expired before delivery (device offline since " + cmd.getIssuedAt() + ")"); + commandRepository.save(cmd); + events.commandTerminated(cmd); + log.info("Command {} ({}) for device {} expired undelivered", cmd.getId(), cmd.getKind(), cmd.getDeviceId()); + } + + /** Null {@code expiresAt} means a row predating expiry support; treat it as non-expiring. */ + static boolean isExpired(DeviceCommand cmd, Instant now) { + return cmd.getExpiresAt() != null && cmd.getExpiresAt().isBefore(now); } private void deliverTo(DeviceCommand cmd, AgentSession session) { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java new file mode 100644 index 0000000..d73fcdf --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaper.java @@ -0,0 +1,83 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Drives commands to a terminal state when the agent never will. + *

+ * Two failure modes are covered. A command can sit PENDING because the device never came + * back — that becomes {@link DeviceCommandStatus#EXPIRED}. Or the agent can take delivery + * and then die mid-execution, leaving the row DELIVERED/ACKED/RUNNING forever — that + * becomes {@link DeviceCommandStatus#TIMEOUT}. Without this, both statuses were declared + * but never assigned, and the admin UI showed commands as permanently in flight. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DeviceCommandReaper { + + /** Non-terminal states that imply the agent has the command and owes a result. */ + private static final Set IN_FLIGHT = EnumSet.of( + DeviceCommandStatus.DELIVERED, + DeviceCommandStatus.ACKED, + DeviceCommandStatus.RUNNING); + + /** Slack on top of a command's own deadline, covering agent→backend result latency. */ + private static final Duration RESULT_GRACE = Duration.ofSeconds(15); + + private final DeviceCommandRepository commandRepository; + private final CommandDispatcher dispatcher; + private final DeviceEventPublisher events; + + @Scheduled(fixedDelayString = "${musallahboard.command.reaperIntervalMs:60000}") + @Transactional + public void reap() { + Instant now = Instant.now(); + expireUndelivered(now); + timeoutInFlight(now); + } + + private void expireUndelivered(Instant now) { + List stale = + commandRepository.findByStatusAndExpiresAtBefore(DeviceCommandStatus.PENDING, now); + for (DeviceCommand cmd : stale) { + dispatcher.expire(cmd, now); + } + } + + private void timeoutInFlight(Instant now) { + Instant cutoff = now.minus(RESULT_GRACE); + List candidates = + commandRepository.findByStatusInAndDeliveredAtBefore(IN_FLIGHT, cutoff); + + for (DeviceCommand cmd : candidates) { + Instant deadline = cmd.getDeliveredAt() + .plusMillis(cmd.getDeadlineMs() == null ? 0 : cmd.getDeadlineMs()) + .plus(RESULT_GRACE); + if (!deadline.isBefore(now)) continue; + + DeviceCommandStatus stuckAt = cmd.getStatus(); + cmd.setStatus(DeviceCommandStatus.TIMEOUT); + cmd.setFinishedAt(now); + cmd.setErrorMessage("No result from agent within " + cmd.getDeadlineMs() + "ms of delivery"); + commandRepository.save(cmd); + events.commandTerminated(cmd); + log.warn("Command {} ({}) for device {} timed out while {}", + cmd.getId(), cmd.getKind(), cmd.getDeviceId(), stuckAt); + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java index 771bd9e..bf456ba 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/DeviceEnrollmentService.java @@ -95,10 +95,7 @@ public Outcome enroll(String plaintextToken, .timezone("America/Toronto") .method(CalculationMethod.ISNA) .build()) - .posterCycleIntervalMs(10000) - .refreshAfterIshaMinutes(30) .darkModeAfterIsha(true) - .darkModeAfterMaghribMinutes(45) .enableScrollingMessage(true) .scrollingMessages(List.of("Welcome to UTM MSA - Follow us @utmmsa for updates!")) .build(); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java index 6340020..8bc6938 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java @@ -31,15 +31,22 @@ public class HeartbeatService { public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { Instant now = Instant.now(); + HeartbeatFrame.Telemetry t = frame.getTelemetry(); + deviceRepository.findById(deviceId).ifPresent(device -> { device.setLastHeartbeat(now); if (remoteIp != null && !remoteIp.isBlank()) { device.setLastSeenIp(remoteIp); } + // Enrollment set this once; a binary push changes it without re-enrolling. + String reported = t == null ? null : t.getAgentVersion(); + if (reported != null && !reported.isBlank() && !reported.equals(device.getAgentVersion())) { + log.info("Device {} agent version {} → {}", deviceId, device.getAgentVersion(), reported); + device.setAgentVersion(reported); + } deviceRepository.save(device); }); - HeartbeatFrame.Telemetry t = frame.getTelemetry(); if (t == null) return; DeviceTelemetry row = DeviceTelemetry.builder() @@ -54,10 +61,15 @@ public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { .kioskAlive(t.getKioskAlive()) .ipv4(t.getIpv4() == null ? null : String.join(",", t.getIpv4())) .wifiSsid(t.getWifiSsid()) - .displayedFrameId(t.getDisplayedFrameId()) + .displayedFrameKey(truncate(t.getDisplayedFrameKey(), DeviceTelemetry.MAX_FRAME_KEY_LENGTH)) .build(); telemetryRepository.save(row); events.heartbeat(deviceId, frame); } + + private static String truncate(String value, int max) { + if (value == null || value.length() <= max) return value; + return value.substring(0, max); + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java index 12fb1a0..f1dd7a5 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/events/DeviceEventPublisher.java @@ -79,6 +79,16 @@ public void commandResult(DeviceCommand cmd, CommandResultFrame frame) { publishCommandEvent(cmd, "result", extra); } + /** + * Terminal transition the backend decided on its own (expiry, timeout) rather than one + * the agent reported. Emitted as {@code result} so dashboards need only one handler. + */ + public void commandTerminated(DeviceCommand cmd) { + Map extra = new LinkedHashMap<>(); + extra.put("errorMessage", cmd.getErrorMessage()); + publishCommandEvent(cmd, "result", extra); + } + private void publishCommandEvent(DeviceCommand cmd, String event, Map extra) { Map body = new LinkedHashMap<>(); body.put("event", event); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java index aae0889..bfdb44d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java @@ -1,41 +1,58 @@ package com.ibrasoft.lensbridge.service.board; -import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import lombok.Builder; import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import java.time.DateTimeException; import java.time.DayOfWeek; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.TemporalAdjusters; +/** + * Everything a payload assembly needs to know about "when" and "where" for one board. + *

+ * All boundaries are computed in the device's own timezone, never the server's. A board in + * Toronto and a backend container running UTC must agree on which events are "today". + *

+ * Weeks run Monday to Sunday, matching {@link com.ibrasoft.lensbridge.model.board.WeekId}, + * which keys the {@code weekly_content} table. The two used to disagree — events were + * bucketed Sunday–Saturday — so on Sundays a board showed next week's events next to last + * week's jummah times. + */ @Value @Builder +@Slf4j public class BoardContext { + Device device; DeviceConfig config; ZonedDateTime now; + /** The device's timezone, already resolved into {@link #now}. */ public ZoneId zone() { - if (config != null && config.getLocation() != null && config.getLocation().getTimezone() != null) { - try { - return ZoneId.of(config.getLocation().getTimezone()); - } catch (Exception ignored) {} - } - return ZoneId.systemDefault(); + return now.getZone(); + } + + /** Local date in the device's timezone — the key for "which week's content". */ + public LocalDate today() { + return now.toLocalDate(); } public Instant currentWeekStart() { - return now.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) + return now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) .with(LocalTime.MIN) .toInstant(); } public Instant currentWeekEnd() { - return now.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY)) + return now.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) .with(LocalTime.MAX) .toInstant(); } @@ -48,17 +65,34 @@ public Instant currentDayEnd() { return now.with(LocalTime.MAX).toInstant(); } - public static BoardContext of(Device device) { + /** + * @param defaultZone used when the device has no timezone configured or the configured + * one is unparseable. Pass the deployment's default, not + * {@code ZoneId.systemDefault()} — the JVM's zone is an accident of + * the container image. + */ + public static BoardContext of(Device device, ZoneId defaultZone) { DeviceConfig config = device.getConfig(); - ZoneId zone = ZoneId.systemDefault(); - if (config != null && config.getLocation() != null && config.getLocation().getTimezone() != null) { - try { zone = ZoneId.of(config.getLocation().getTimezone()); } - catch (Exception ignored) {} - } + ZoneId zone = resolveZone(device, config, defaultZone); return BoardContext.builder() .device(device) .config(config) .now(ZonedDateTime.now(zone)) .build(); } + + private static ZoneId resolveZone(Device device, DeviceConfig config, ZoneId defaultZone) { + String configured = config == null || config.getLocation() == null + ? null + : config.getLocation().getTimezone(); + if (configured == null || configured.isBlank()) return defaultZone; + + try { + return ZoneId.of(configured); + } catch (DateTimeException e) { + log.warn("Device {} has an unparseable timezone '{}' — falling back to {}", + device.getId(), configured, defaultZone); + return defaultZone; + } + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index c333ee5..9595725 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -1,10 +1,10 @@ package com.ibrasoft.lensbridge.service.board; -import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; @@ -12,21 +12,24 @@ import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.board.transformer.DailyScheduleFrameTransformer; import com.ibrasoft.lensbridge.service.board.transformer.EventListFrameTransformer; import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; import com.ibrasoft.lensbridge.service.board.transformer.WeeklyContentFrameTransformer; -import lombok.RequiredArgsConstructor; + +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.UUID; @Service -@RequiredArgsConstructor @Slf4j +@AllArgsConstructor public class BoardPayloadAssembler { private final BoardService boardService; @@ -34,19 +37,23 @@ public class BoardPayloadAssembler { private final DeviceRepository deviceRepository; private final PosterFrameTransformer posterTransformer; private final EventListFrameTransformer eventListTransformer; + private final DailyScheduleFrameTransformer dailyScheduleTransformer; private final WeeklyContentFrameTransformer weeklyContentTransformer; private final OpenWeatherService openWeatherService; + private final ZoneId defaultZone; + public MusallahBoardPayload assemble(UUID deviceId) { Device device = deviceRepository.findById(deviceId) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Device not found: " + deviceId))); - BoardContext ctx = BoardContext.of(device); + BoardContext ctx = BoardContext.of(device, defaultZone); List frames = new ArrayList<>(); frames.addAll(posterFrames(ctx)); + frames.addAll(dailyScheduleFrames(ctx)); frames.addAll(eventFrames(ctx)); frames.addAll(weeklyContentFrames(ctx)); @@ -64,6 +71,13 @@ private List posterFrames(BoardContext ctx) { return out; } + private List dailyScheduleFrames(BoardContext ctx) { + List todaysEvents = boardService.getEventsForAudienceInRange( + ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.currentDayEnd()); + if (todaysEvents.isEmpty()) return List.of(); + return List.of(dailyScheduleTransformer.transform(todaysEvents, ctx)); + } + private List eventFrames(BoardContext ctx) { List boardEvents = boardService.getEventsForAudienceInRange( ctx.getDevice().getAudience(), ctx.currentWeekStart(), ctx.currentWeekEnd()); @@ -72,7 +86,7 @@ private List eventFrames(BoardContext ctx) { } private List weeklyContentFrames(BoardContext ctx) { - WeeklyContent content = boardService.getCurrentWeeklyContent().orElse(null); + WeeklyContent content = boardService.getWeeklyContentFor(ctx.today()).orElse(null); return weeklyContentTransformer.transform(content, ctx); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java index 62043e3..671f320 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java @@ -27,7 +27,7 @@ public FrameType supports() { @Override public FrameDefinition transform(List boardEvents, BoardContext ctx) { List eventViews = boardEvents.stream() - .map(this::toEventView) + .map(EventView::of) .collect(Collectors.toList()); DailyScheduleFrameConfig config = DailyScheduleFrameConfig.builder() @@ -44,14 +44,4 @@ public FrameDefinition transform(List boardEvents, BoardContext ctx) .build(); } - private EventView toEventView(BoardEvent boardEvent) { - return EventView.builder() - .name(boardEvent.getName()) - .description(boardEvent.getDescription()) - .location(boardEvent.getLocation()) - .startTime(boardEvent.getStartTime()) - .endTime(boardEvent.getEndTime()) - .allDay(boardEvent.getAllDay()) - .build(); - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java index d0a9df2..2bd318b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java @@ -27,7 +27,7 @@ public FrameType supports() { @Override public FrameDefinition transform(List boardEvents, BoardContext ctx) { List eventViews = boardEvents.stream() - .map(this::toEventView) + .map(EventView::of) .collect(Collectors.toList()); EventListFrameConfig config = EventListFrameConfig.builder() @@ -44,14 +44,4 @@ public FrameDefinition transform(List boardEvents, BoardContext ctx) .build(); } - private EventView toEventView(BoardEvent boardEvent) { - return EventView.builder() - .name(boardEvent.getName()) - .description(boardEvent.getDescription()) - .location(boardEvent.getLocation()) - .startTime(boardEvent.getStartTime()) - .endTime(boardEvent.getEndTime()) - .allDay(boardEvent.getAllDay()) - .build(); - } } diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example index 5c4246d..04b2560 100644 --- a/src/main/resources/application.properties.example +++ b/src/main/resources/application.properties.example @@ -19,6 +19,19 @@ spring.jpa.open-in-view=false frontend.baseurl=http://localhost:5173 musallahboard.baseurl=http://localhost:3000 +# MusallahBoard device agents. websocketUrl is returned by /api/agent/enroll and +# persisted verbatim on the device, so point it at the address agents can reach +# from where they are deployed — not at whatever host happens to serve this app. +musallahboard.agent.websocketUrl=ws://localhost:8080/api/agent/ws +# Optional extra input to the enrollment-token hash. Leave empty to disable. +musallahboard.enrollment.tokenPepper= +# Zone used for a board whose device config has no timezone, or an unparseable one. +# Deliberately not the JVM default — the container's zone is an accident of the base image, +# and getting it wrong silently shifts every day and week boundary on the screen. +musallahboard.defaultTimezone=America/Toronto +# How often undelivered device commands are expired and in-flight ones timed out. +musallahboard.command.reaperIntervalMs=60000 + # Cloudflare R2 cloudflare.r2.endpoint=https://.r2.cloudflarestorage.com cloudflare.r2.access-key-id=replace-with-r2-access-key diff --git a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java index aedd631..5803821 100644 --- a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.dto.board.agent.HeartbeatFrame; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; @@ -14,6 +15,7 @@ import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketMessage; @@ -210,6 +212,32 @@ void heartbeat_persistsViaService() throws Exception { assertTrue(s.isOpen, "heartbeat should not close session"); } + /** + * Regression: the agent reports the kiosk's slide key ("next-prayer", "poster-3"), which + * is not a UUID. While the field was typed UUID every heartbeat from a working board + * failed to parse and the session was closed with 4004, so devices looked permanently + * offline. The legacy displayedFrameId name must keep working. + */ + @Test + void heartbeat_acceptsNonUuidFrameKey() throws Exception { + KeyPair kp = Ed25519TestUtil.generate(); + Device device = persistedDeviceWith(Ed25519TestUtil.rawPublicKey(kp.getPublic())); + + FakeSession s = openSession(); + Hello hello = parseHello(s.outbox.get(0)); + deliverAuth(s, hello.sessionId, hello.challenge, device.getId(), System.currentTimeMillis(), kp); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HeartbeatFrame.class); + deliver(s, String.format( + "{\"type\":\"heartbeat\",\"seq\":2,\"sessionId\":\"%s\"," + + "\"telemetry\":{\"kioskAlive\":true,\"displayedFrameId\":\"next-prayer\"}}", + hello.sessionId)); + + verify(heartbeatService).record(eq(device.getId()), captor.capture(), any()); + assertEquals("next-prayer", captor.getValue().getTelemetry().getDisplayedFrameKey()); + assertTrue(s.isOpen, "a non-UUID frame key must not close the session"); + } + @Test void afterClose_unregistersFromRegistry() throws Exception { KeyPair kp = Ed25519TestUtil.generate(); diff --git a/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java new file mode 100644 index 0000000..4a6b91f --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java @@ -0,0 +1,225 @@ +package com.ibrasoft.lensbridge.handler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.net.URI; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BoardStreamHandlerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private DeviceRepository deviceRepository; + private BoardStreamHandler handler; + + @BeforeEach + void setUp() { + deviceRepository = mock(DeviceRepository.class); + when(deviceRepository.findById(any())).thenReturn(Optional.empty()); + handler = new BoardStreamHandler(mapper, deviceRepository); + } + + @Test + void contentPushReachesEveryConnectedBoard() throws Exception { + Session a = connect(enrolled()); + Session b = connect(enrolled()); + + handler.contentChanged("posters"); + + assertThat(a.sent).hasSize(1); + assertThat(b.sent).hasSize(1); + } + + @Test + void configPushReachesOnlyTheTargetBoard() throws Exception { + UUID target = enrolled(); + Session mine = connect(target); + Session theirs = connect(enrolled()); + + handler.configChanged(target); + + assertThat(mine.sent).hasSize(1); + assertThat(theirs.sent).isEmpty(); + } + + @Test + void messageIsJsonCarryingTypeReasonAndTarget() throws Exception { + UUID target = enrolled(); + Session s = connect(target); + + handler.configChanged(target); + + JsonNode body = mapper.readTree(s.sent.get(0)); + assertThat(body.get("type").asText()).isEqualTo("refresh"); + assertThat(body.get("reason").asText()).isEqualTo("config"); + assertThat(body.get("deviceId").asText()).isEqualTo(target.toString()); + assertThat(body.hasNonNull("at")).isTrue(); + } + + /** The deployed kiosk substring-matches rather than parsing, so JSON must still hit it. */ + @Test + void messageStillMatchesTheLegacyRefreshSubstring() throws Exception { + Session s = connect(enrolled()); + + handler.refreshAll(); + + assertThat(s.sent.get(0).toUpperCase()).contains("REFRESH"); + } + + // ── enrollment is a precondition, not a state ───────────────────────── + + @Test + void connectionWithoutADeviceIdIsRejected() throws Exception { + Session s = connectWithQuery(null); + + assertThat(s.closedWith).isNotNull(); + assertThat(s.closedWith.getCode()).isEqualTo(4001); + + handler.contentChanged("events"); + assertThat(s.sent).isEmpty(); + } + + @Test + void malformedDeviceIdIsRejected() throws Exception { + Session s = connectWithQuery("deviceId=not-a-uuid"); + + assertThat(s.closedWith.getCode()).isEqualTo(4001); + } + + @Test + void unknownDeviceIsRejected() throws Exception { + // deviceRepository returns empty for anything not explicitly enrolled. + Session s = connectWithQuery("deviceId=" + UUID.randomUUID()); + + assertThat(s.closedWith.getCode()).isEqualTo(4002); + + handler.contentChanged("events"); + assertThat(s.sent).isEmpty(); + } + + @Test + void revokedDeviceIsRejected() throws Exception { + UUID id = UUID.randomUUID(); + Device revoked = Device.builder().id(id).displayName("d").build(); + revoked.setRevokedAt(Instant.now().minusSeconds(60)); + when(deviceRepository.findById(id)).thenReturn(Optional.of(revoked)); + + Session s = connect(id); + + assertThat(s.closedWith.getCode()).isEqualTo(4003); + } + + // ── session lifecycle ───────────────────────────────────────────────── + + @Test + void reconnectSupersedesThePriorSessionForTheSameDevice() throws Exception { + UUID device = enrolled(); + Session first = connect(device); + Session second = connect(device); + + assertThat(first.closedWith).isNotNull(); + + handler.configChanged(device); + assertThat(second.sent).hasSize(1); + assertThat(first.sent).isEmpty(); + } + + /** + * A superseded session's close callback arrives after its replacement is already + * registered. It must not evict the newcomer. + */ + @Test + void lateCloseOfASupersededSessionLeavesTheReplacementRegistered() throws Exception { + UUID device = enrolled(); + Session first = connect(device); + Session second = connect(device); + + handler.afterConnectionClosed(first.ws, CloseStatus.NORMAL); + + handler.configChanged(device); + assertThat(second.sent).hasSize(1); + } + + @Test + void disconnectStopsDelivery() throws Exception { + Session s = connect(enrolled()); + handler.afterConnectionClosed(s.ws, CloseStatus.NORMAL); + + handler.contentChanged("events"); + + assertThat(s.sent).isEmpty(); + } + + @Test + void closedSessionsAreDroppedAndNeverWrittenTo() throws Exception { + Session s = connect(enrolled()); + s.open = false; + + handler.contentChanged("events"); + + assertThat(s.sent).isEmpty(); + } + + // ── helpers ─────────────────────────────────────────────────────────── + + /** Registers a device the repository will vouch for, and returns its id. */ + private UUID enrolled() { + UUID id = UUID.randomUUID(); + when(deviceRepository.findById(id)) + .thenReturn(Optional.of(Device.builder().id(id).displayName("board").build())); + return id; + } + + private Session connect(UUID deviceId) throws Exception { + return connectWithQuery("deviceId=" + deviceId); + } + + private Session connectWithQuery(String query) throws Exception { + Session session = new Session(query); + handler.afterConnectionEstablished(session.ws); + return session; + } + + /** Minimal stand-in that records what the handler writes and how it closes. */ + private static class Session { + final WebSocketSession ws = mock(WebSocketSession.class); + final List sent = new ArrayList<>(); + boolean open = true; + CloseStatus closedWith; + + Session(String query) throws Exception { + when(ws.getId()).thenReturn(UUID.randomUUID().toString()); + when(ws.getUri()).thenReturn(URI.create( + "ws://board.local/api/refresh-musallahboard" + (query == null ? "" : "?" + query))); + when(ws.isOpen()).thenAnswer(inv -> open); + when(ws.getAttributes()).thenReturn(new HashMap<>()); + doAnswer(inv -> { + sent.add(((TextMessage) inv.getArgument(0)).getPayload()); + return null; + }).when(ws).sendMessage(any()); + doAnswer(inv -> { + closedWith = inv.getArgument(0); + open = false; + return null; + }).when(ws).close(any(CloseStatus.class)); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java index c51adfa..f3b04bd 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java @@ -5,6 +5,7 @@ import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.Device; @@ -50,6 +51,8 @@ class BoardServiceTest { private BoardEventRepository boardEventRepository; @Mock private WeeklyContentRepository weeklyContentRepository; + @Mock + private BoardStreamHandler boardStream; @InjectMocks private BoardService service; @@ -57,7 +60,7 @@ class BoardServiceTest { private DeviceConfig config(UUID deviceId) { DeviceConfig c = new DeviceConfig(); c.setId(deviceId); - c.setPosterCycleIntervalMs(5000); + c.setDarkModeAfterIsha(true); return c; } @@ -116,13 +119,11 @@ void updateBoardConfigPatchesOnlyNonNullFields() { when(boardConfigRepository.save(existing)).thenReturn(existing); UpdateBoardConfigRequest request = UpdateBoardConfigRequest.builder() - .posterCycleIntervalMs(9000) .enableScrollingMessage(true) .build(); DeviceConfig saved = service.updateBoardConfig(id, request); - assertThat(saved.getPosterCycleIntervalMs()).isEqualTo(9000); assertThat(saved.isEnableScrollingMessage()).isTrue(); assertThat(saved.getLocation()).isNull(); // untouched } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java index 8e8d631..fac6f59 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; @@ -45,6 +46,8 @@ class PosterServiceTest { @Mock private PosterFrameTransformer posterFrameTransformer; + @Mock + private BoardStreamHandler boardStream; @InjectMocks private PosterService service; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java index d59a7e9..b4c513d 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcherTest.java @@ -60,7 +60,7 @@ void issue_persistsAsPending_whenDeviceOffline() { UUID deviceId = registerDevice(); CommandIssuedResponse resp = dispatcher.issue(deviceId, "admin@example.com", - new IssueCommandRequest("chrome.reload", null, null)); + new IssueCommandRequest("chrome.reload", null, null, null)); assertEquals(deviceId, resp.deviceId()); assertEquals(DeviceCommandStatus.PENDING, resp.status()); @@ -73,7 +73,7 @@ void issue_pushesToLiveSession_andMarksDelivered() throws Exception { UUID deviceId = registerDevice(); AgentSession session = liveSession(deviceId); - dispatcher.issue(deviceId, "admin", new IssueCommandRequest("chrome.reload", null, 15_000)); + dispatcher.issue(deviceId, "admin", new IssueCommandRequest("chrome.reload", null, 15_000, null)); verify(session.getTransport(), atLeastOnce()).sendMessage(any()); verify(events).commandDelivered(any()); @@ -85,7 +85,7 @@ void issue_leavesPending_whenRegisteredSessionIsClosed() { AgentSession session = closedSession(deviceId); CommandIssuedResponse resp = dispatcher.issue(deviceId, "admin", - new IssueCommandRequest("chrome.reload", null, 15_000)); + new IssueCommandRequest("chrome.reload", null, 15_000, null)); assertEquals(DeviceCommandStatus.PENDING, resp.status()); verify(events, never()).commandDelivered(any()); @@ -95,7 +95,7 @@ void issue_leavesPending_whenRegisteredSessionIsClosed() { void issue_rejectsUnknownKind() { UUID deviceId = registerDevice(); ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - dispatcher.issue(deviceId, "admin", new IssueCommandRequest("not.a.thing", null, null))); + dispatcher.issue(deviceId, "admin", new IssueCommandRequest("not.a.thing", null, null, null))); assertEquals(400, ex.getStatusCode().value()); } @@ -107,7 +107,7 @@ void issue_rejectsRevokedDevice() { when(deviceRepo.findById(deviceId)).thenReturn(Optional.of(d)); ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - dispatcher.issue(deviceId, "admin", new IssueCommandRequest("chrome.reload", null, null))); + dispatcher.issue(deviceId, "admin", new IssueCommandRequest("chrome.reload", null, null, null))); assertEquals(409, ex.getStatusCode().value()); } @@ -180,6 +180,65 @@ void flushPending_deliversAllPendingCommands() { verify(events, times(2)).commandDelivered(any()); } + @Test + void flushPending_expiresStaleCommandsInsteadOfDelivering() { + UUID deviceId = registerDevice(); + AgentSession session = liveSession(deviceId); + + DeviceCommand fresh = persistedCommand(deviceId, DeviceCommandStatus.PENDING); + DeviceCommand stale = persistedCommand(deviceId, DeviceCommandStatus.PENDING); + stale.setKind("system.reboot"); + stale.setExpiresAt(Instant.now().minusSeconds(1)); + when(commandRepo.findByDeviceIdAndStatusOrderByIssuedAtAsc(deviceId, DeviceCommandStatus.PENDING)) + .thenReturn(List.of(stale, fresh)); + + dispatcher.flushPending(deviceId, session); + + assertEquals(DeviceCommandStatus.EXPIRED, stale.getStatus()); + assertNotNull(stale.getFinishedAt()); + assertNotNull(stale.getErrorMessage()); + assertEquals(DeviceCommandStatus.DELIVERED, fresh.getStatus()); + verify(events, times(1)).commandDelivered(any()); + verify(events, times(1)).commandTerminated(eq(stale)); + } + + @Test + void flushPending_treatsMissingExpiryAsNonExpiring() { + UUID deviceId = registerDevice(); + AgentSession session = liveSession(deviceId); + + // Rows written before expiry support carry a null expiresAt. + DeviceCommand legacy = persistedCommand(deviceId, DeviceCommandStatus.PENDING); + legacy.setExpiresAt(null); + when(commandRepo.findByDeviceIdAndStatusOrderByIssuedAtAsc(deviceId, DeviceCommandStatus.PENDING)) + .thenReturn(List.of(legacy)); + + dispatcher.flushPending(deviceId, session); + + assertEquals(DeviceCommandStatus.DELIVERED, legacy.getStatus()); + } + + @Test + void issue_stampsExpiryFromDefaultTtl() { + UUID deviceId = registerDevice(); + + CommandIssuedResponse resp = dispatcher.issue(deviceId, "admin", + new IssueCommandRequest("chrome.reload", null, null, null)); + + assertNotNull(resp.expiresAt()); + assertTrue(resp.expiresAt().isAfter(resp.issuedAt())); + } + + @Test + void issue_honoursExplicitTtl() { + UUID deviceId = registerDevice(); + + CommandIssuedResponse resp = dispatcher.issue(deviceId, "admin", + new IssueCommandRequest("system.reboot", null, null, 30)); + + assertEquals(30, resp.expiresAt().getEpochSecond() - resp.issuedAt().getEpochSecond()); + } + // ── helpers ─────────────────────────────────────────────────────────── private UUID registerDevice() { @@ -207,6 +266,7 @@ private DeviceCommand persistedCommand(UUID deviceId, DeviceCommandStatus status .deadlineMs(30_000) .status(status) .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) .build(); when(commandRepo.findById(cmd.getId())).thenReturn(Optional.of(cmd)); return cmd; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaperTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaperTest.java new file mode 100644 index 0000000..0016d38 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/DeviceCommandReaperTest.java @@ -0,0 +1,113 @@ +package com.ibrasoft.lensbridge.service.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.board.DeviceCommand; +import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; +import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DeviceCommandReaperTest { + + private static final int DEADLINE_MS = 30_000; + + private DeviceCommandRepository commandRepo; + private DeviceEventPublisher events; + private DeviceCommandReaper reaper; + + @BeforeEach + void setUp() { + commandRepo = mock(DeviceCommandRepository.class); + events = mock(DeviceEventPublisher.class); + CommandDispatcher dispatcher = new CommandDispatcher( + commandRepo, mock(DeviceRepository.class), new AgentSessionRegistry(), + new ObjectMapper(), events); + reaper = new DeviceCommandReaper(commandRepo, dispatcher, events); + + when(commandRepo.findByStatusAndExpiresAtBefore(any(), any())).thenReturn(List.of()); + when(commandRepo.findByStatusInAndDeliveredAtBefore(any(), any())).thenReturn(List.of()); + when(commandRepo.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + void expiresPendingCommandsPastTheirDeliveryWindow() { + DeviceCommand stale = command(DeviceCommandStatus.PENDING); + stale.setExpiresAt(Instant.now().minusSeconds(60)); + when(commandRepo.findByStatusAndExpiresAtBefore(eq(DeviceCommandStatus.PENDING), any())) + .thenReturn(List.of(stale)); + + reaper.reap(); + + assertEquals(DeviceCommandStatus.EXPIRED, stale.getStatus()); + assertNotNull(stale.getFinishedAt()); + verify(events).commandTerminated(stale); + } + + @ParameterizedTest + @EnumSource(value = DeviceCommandStatus.class, names = {"DELIVERED", "ACKED", "RUNNING"}) + void timesOutInFlightCommandsThatNeverReportBack(DeviceCommandStatus stuckAt) { + DeviceCommand stuck = command(stuckAt); + stuck.setDeliveredAt(Instant.now().minusMillis(DEADLINE_MS).minusSeconds(60)); + when(commandRepo.findByStatusInAndDeliveredAtBefore(anyCollection(), any())) + .thenReturn(List.of(stuck)); + + reaper.reap(); + + assertEquals(DeviceCommandStatus.TIMEOUT, stuck.getStatus()); + assertNotNull(stuck.getFinishedAt()); + verify(events).commandTerminated(stuck); + } + + @Test + void leavesInFlightCommandsAloneWhileTheirDeadlineHolds() { + DeviceCommand running = command(DeviceCommandStatus.RUNNING); + running.setDeliveredAt(Instant.now()); + when(commandRepo.findByStatusInAndDeliveredAtBefore(anyCollection(), any())) + .thenReturn(List.of(running)); + + reaper.reap(); + + assertEquals(DeviceCommandStatus.RUNNING, running.getStatus()); + verify(events, never()).commandTerminated(any()); + } + + @Test + void doesNothingWhenThereIsNothingToReap() { + reaper.reap(); + + verify(commandRepo, never()).save(any()); + verify(events, never()).commandTerminated(any()); + } + + private static DeviceCommand command(DeviceCommandStatus status) { + return DeviceCommand.builder() + .id(UUID.randomUUID()) + .deviceId(UUID.randomUUID()) + .kind("chrome.reload") + .payloadJson("{}") + .issuedBy("admin") + .deadlineMs(DEADLINE_MS) + .status(status) + .issuedAt(Instant.now().minusSeconds(600)) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java index 03b892c..3c847ee 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java @@ -48,7 +48,7 @@ private HeartbeatFrame frameWithTelemetry() { t.setKioskAlive(true); t.setIpv4(List.of("10.0.0.1", "192.168.1.2")); t.setWifiSsid("MSA-WIFI"); - t.setDisplayedFrameId(UUID.randomUUID()); + t.setDisplayedFrameKey("poster-3"); frame.setTelemetry(t); return frame; } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java index 66fd107..17415d7 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java @@ -7,6 +7,7 @@ import java.time.DayOfWeek; import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -15,6 +16,12 @@ class BoardContextTest { + private static final ZoneId DEFAULT_ZONE = ZoneId.of("America/Toronto"); + + /** A Wednesday, so week boundaries land either side of it. */ + private static final ZonedDateTime WEDNESDAY = + ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); + private static Device deviceWithTimezone(String timezone) { Location location = Location.builder().timezone(timezone).build(); DeviceConfig config = DeviceConfig.builder().location(location).build(); @@ -23,119 +30,108 @@ private static Device deviceWithTimezone(String timezone) { return device; } - @Test - void zoneResolvesFromConfigTimezone() { - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("America/Toronto")); - BoardContext ctx = BoardContext.builder() - .device(deviceWithTimezone("America/Toronto")) - .config(deviceWithTimezone("America/Toronto").getConfig()) - .now(now) - .build(); - - assertThat(ctx.zone()).isEqualTo(ZoneId.of("America/Toronto")); + private static BoardContext at(ZonedDateTime now) { + return BoardContext.builder().now(now).build(); } @Test - void zoneFallsBackToSystemDefaultWhenConfigNull() { - BoardContext ctx = BoardContext.builder() - .device(Device.builder().displayName("d").build()) - .config(null) - .now(ZonedDateTime.now()) - .build(); - - assertThat(ctx.zone()).isEqualTo(ZoneId.systemDefault()); + void zoneIsTheZoneOfNow() { + assertThat(at(ZonedDateTime.now(DEFAULT_ZONE)).zone()).isEqualTo(DEFAULT_ZONE); } @Test - void zoneFallsBackToSystemDefaultWhenTimezoneInvalid() { - Location location = Location.builder().timezone("Not/AZone").build(); - DeviceConfig config = DeviceConfig.builder().location(location).build(); + void ofResolvesZoneFromConfigTimezone() { + Device device = deviceWithTimezone("Asia/Dubai"); - BoardContext ctx = BoardContext.builder() - .device(Device.builder().displayName("d").build()) - .config(config) - .now(ZonedDateTime.now()) - .build(); + BoardContext ctx = BoardContext.of(device, DEFAULT_ZONE); - assertThat(ctx.zone()).isEqualTo(ZoneId.systemDefault()); + assertThat(ctx.getDevice()).isSameAs(device); + assertThat(ctx.getConfig()).isSameAs(device.getConfig()); + assertThat(ctx.zone()).isEqualTo(ZoneId.of("Asia/Dubai")); } @Test - void currentWeekStartIsPreviousOrSameSundayAtMidnight() { - // Wednesday 2026-05-13 12:34 UTC - ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); - BoardContext ctx = BoardContext.builder().now(now).build(); + void ofFallsBackToSuppliedDefaultWhenConfigMissing() { + BoardContext ctx = BoardContext.of(Device.builder().displayName("d").build(), DEFAULT_ZONE); - ZonedDateTime weekStart = ctx.currentWeekStart().atZone(ZoneOffset.UTC); - - assertThat(weekStart.getDayOfWeek()).isEqualTo(DayOfWeek.SUNDAY); - assertThat(weekStart.toLocalDate()).isEqualTo(now.toLocalDate().minusDays(3)); - assertThat(weekStart.getHour()).isZero(); - assertThat(weekStart.getMinute()).isZero(); + assertThat(ctx.getConfig()).isNull(); + assertThat(ctx.zone()).isEqualTo(DEFAULT_ZONE); } @Test - void currentWeekEndIsNextOrSameSaturdayEndOfDay() { - ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); - BoardContext ctx = BoardContext.builder().now(now).build(); + void ofFallsBackToSuppliedDefaultWhenTimezoneInvalid() { + assertThat(BoardContext.of(deviceWithTimezone("Bogus/Zone"), DEFAULT_ZONE).zone()) + .isEqualTo(DEFAULT_ZONE); + } - ZonedDateTime weekEnd = ctx.currentWeekEnd().atZone(ZoneOffset.UTC); + @Test + void ofUsesTheSuppliedDefaultRatherThanTheJvmZone() { + ZoneId elsewhere = ZoneId.of("Asia/Tokyo"); - assertThat(weekEnd.getDayOfWeek()).isEqualTo(DayOfWeek.SATURDAY); - assertThat(weekEnd.toLocalDate()).isEqualTo(now.toLocalDate().plusDays(3)); - assertThat(weekEnd.getHour()).isEqualTo(23); - assertThat(weekEnd.getMinute()).isEqualTo(59); + assertThat(BoardContext.of(deviceWithTimezone(null), elsewhere).zone()).isEqualTo(elsewhere); } @Test - void weekStartIsBeforeWeekEnd() { - ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); - BoardContext ctx = BoardContext.builder().now(now).build(); + void currentWeekStartIsPreviousOrSameMondayAtMidnight() { + ZonedDateTime weekStart = at(WEDNESDAY).currentWeekStart().atZone(ZoneOffset.UTC); - assertThat(ctx.currentWeekStart()).isBefore(ctx.currentWeekEnd()); + assertThat(weekStart.getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); + assertThat(weekStart.toLocalDate()).isEqualTo(WEDNESDAY.toLocalDate().minusDays(2)); + assertThat(weekStart.getHour()).isZero(); + assertThat(weekStart.getMinute()).isZero(); } @Test - void currentDayStartAndEndBracketTheMoment() { - ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); - BoardContext ctx = BoardContext.builder().now(now).build(); - - Instant dayStart = ctx.currentDayStart(); - Instant dayEnd = ctx.currentDayEnd(); + void currentWeekEndIsNextOrSameSundayEndOfDay() { + ZonedDateTime weekEnd = at(WEDNESDAY).currentWeekEnd().atZone(ZoneOffset.UTC); - assertThat(dayStart).isBeforeOrEqualTo(now.toInstant()); - assertThat(dayEnd).isAfter(now.toInstant()); - assertThat(dayStart.atZone(ZoneOffset.UTC).toLocalDate()) - .isEqualTo(now.toLocalDate()); + assertThat(weekEnd.getDayOfWeek()).isEqualTo(DayOfWeek.SUNDAY); + assertThat(weekEnd.toLocalDate()).isEqualTo(WEDNESDAY.toLocalDate().plusDays(4)); + assertThat(weekEnd.getHour()).isEqualTo(23); + assertThat(weekEnd.getMinute()).isEqualTo(59); } + /** + * The bug the ISO convention fixes: under Sunday-start weeks a board on Sunday looked + * ahead to next week's events while still showing the outgoing week's jummah times. + */ @Test - void ofBuildsContextFromDeviceConfigTimezone() { - Device device = deviceWithTimezone("America/Toronto"); + void sundayBelongsToTheWeekThatIsEnding() { + ZonedDateTime sunday = WEDNESDAY.plusDays(4); + BoardContext ctx = at(sunday); - BoardContext ctx = BoardContext.of(device); + assertThat(ctx.currentWeekStart().atZone(ZoneOffset.UTC).toLocalDate()) + .isEqualTo(WEDNESDAY.toLocalDate().minusDays(2)); + assertThat(ctx.currentWeekEnd()).isAfter(sunday.toInstant()); + } - assertThat(ctx.getDevice()).isSameAs(device); - assertThat(ctx.getConfig()).isSameAs(device.getConfig()); - assertThat(ctx.getNow().getZone()).isEqualTo(ZoneId.of("America/Toronto")); + @Test + void weekBracketsTheMoment() { + BoardContext ctx = at(WEDNESDAY); + + assertThat(ctx.currentWeekStart()).isBefore(WEDNESDAY.toInstant()); + assertThat(ctx.currentWeekEnd()).isAfter(WEDNESDAY.toInstant()); } @Test - void ofUsesSystemDefaultWhenConfigMissing() { - Device device = Device.builder().displayName("d").build(); + void currentDayStartAndEndBracketTheMoment() { + BoardContext ctx = at(WEDNESDAY); - BoardContext ctx = BoardContext.of(device); + Instant dayStart = ctx.currentDayStart(); + Instant dayEnd = ctx.currentDayEnd(); - assertThat(ctx.getConfig()).isNull(); - assertThat(ctx.getNow().getZone()).isEqualTo(ZoneId.systemDefault()); + assertThat(dayStart).isBeforeOrEqualTo(WEDNESDAY.toInstant()); + assertThat(dayEnd).isAfter(WEDNESDAY.toInstant()); + assertThat(dayStart.atZone(ZoneOffset.UTC).toLocalDate()).isEqualTo(WEDNESDAY.toLocalDate()); } @Test - void ofFallsBackToSystemDefaultWhenTimezoneInvalid() { - Device device = deviceWithTimezone("Bogus/Zone"); + void todayIsTheLocalDateInTheDeviceZone() { + // 00:30 UTC on the 14th is still the 13th in Toronto. + ZonedDateTime justAfterMidnightUtc = ZonedDateTime.of(2026, 5, 14, 0, 30, 0, 0, ZoneOffset.UTC); - BoardContext ctx = BoardContext.of(device); + BoardContext ctx = at(justAfterMidnightUtc.withZoneSameInstant(DEFAULT_ZONE)); - assertThat(ctx.getNow().getZone()).isEqualTo(ZoneId.systemDefault()); + assertThat(ctx.today()).isEqualTo(LocalDate.of(2026, 5, 13)); } } From 01d9752725c2cf473cb2e917e5047711587b678a Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 2 Aug 2026 21:09:47 -0400 Subject: [PATCH 32/52] Added OpenAPI Docs + CI for consumers --- .github/workflows/openapi.yml | 61 + openapi.yaml | 4005 +++++++++++++++++ pom.xml | 87 +- .../lensbridge/config/OpenApiConfig.java | 51 +- 4 files changed, 4196 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/openapi.yml create mode 100644 openapi.yaml diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..c1e4e0d --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,61 @@ +name: OpenAPI contract + +# openapi.yaml is the contract three separate repositories generate their clients +# from: LensBridgeFrontend, MusallahBoard (kiosk) and the Go agent. It is committed +# so that a change to a response shape shows up as a reviewable diff -- this job +# exists to make sure the committed copy actually matches the controllers. + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + + - name: Set up Python for the spec quality gate + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - run: pip install pyyaml + + - name: Run tests + run: ./mvnw --batch-mode test + + - name: Regenerate openapi.yaml + # Boots the app on a dedicated port and scrapes /v3/api-docs.yaml. + run: ./mvnw --batch-mode -P openapi verify -DskipTests + + - name: Fail if the committed spec is stale + run: | + if ! git diff --exit-code --stat -- openapi.yaml; then + echo "::error::openapi.yaml does not match the controllers." + echo "A contract change was made without regenerating the spec." + echo "Run './mvnw -P openapi verify -DskipTests' and commit openapi.yaml." + git diff -- openapi.yaml + exit 1 + fi + echo "openapi.yaml matches the controllers." + + - name: Check spec quality + # Untyped responses generate `unknown` in TypeScript and `interface{}` in Go, + # which is indistinguishable from having no contract at all. + run: python tools/check_openapi.py openapi.yaml --strict-ids + + - name: Publish the spec for downstream repos + uses: actions/upload-artifact@v4 + with: + name: openapi + path: openapi.yaml + if-no-files-found: error diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..85a7b0b --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,4005 @@ +openapi: 3.1.0 +info: + description: LensBridge backend — media upload and MusallahBoard management + title: LensBridge API + version: 0.0.1-SNAPSHOT +servers: +- url: / +security: +- bearerAuth: [] +paths: + /api/admin/audit: + get: + operationId: getAuditEvents + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/action/{action}: + get: + operationId: getAuditEventsByAction + parameters: + - in: path + name: action + required: true + schema: + type: string + enum: + - APPROVE_UPLOAD + - UNAPPROVE_UPLOAD + - DELETE_UPLOAD + - FEATURE_UPLOAD + - UNFEATURE_UPLOAD + - CREATE_EVENT + - UPDATE_EVENT + - DELETE_EVENT + - CREATE_CALENDAR_EVENT + - UPDATE_CALENDAR_EVENT + - DELETE_CALENDAR_EVENT + - CREATE_POSTER + - UPDATE_POSTER + - DELETE_POSTER + - PROMOTE_USER + - DEMOTE_USER + - DISABLE_USER + - ENABLE_USER + - VIEW_AUDIT_LOGS + - EXPORT_DATA + - SYSTEM_MAINTENANCE + - VERIFY_USER + - UNVERIFY_USER + - RESET_USER_PASSWORD + - TRIGGER_PASSWORD_RESET_EMAIL + - ADD_USER_ROLE + - REMOVE_USER_ROLE + - ADD_USER + - REMOVE_USER + - UPDATE_USER + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/actions: + get: + operationId: getAvailableAuditActions + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: string + enum: + - APPROVE_UPLOAD + - UNAPPROVE_UPLOAD + - DELETE_UPLOAD + - FEATURE_UPLOAD + - UNFEATURE_UPLOAD + - CREATE_EVENT + - UPDATE_EVENT + - DELETE_EVENT + - CREATE_CALENDAR_EVENT + - UPDATE_CALENDAR_EVENT + - DELETE_CALENDAR_EVENT + - CREATE_POSTER + - UPDATE_POSTER + - DELETE_POSTER + - PROMOTE_USER + - DEMOTE_USER + - DISABLE_USER + - ENABLE_USER + - VIEW_AUDIT_LOGS + - EXPORT_DATA + - SYSTEM_MAINTENANCE + - VERIFY_USER + - UNVERIFY_USER + - RESET_USER_PASSWORD + - TRIGGER_PASSWORD_RESET_EMAIL + - ADD_USER_ROLE + - REMOVE_USER_ROLE + - ADD_USER + - REMOVE_USER + - UPDATE_USER + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List the audit action types available for filtering + tags: + - admin-controller + /api/admin/audit/daterange: + get: + operationId: getAuditEventsByDateRange + parameters: + - in: query + name: start + required: true + schema: + type: string + format: date-time + - in: query + name: end + required: true + schema: + type: string + format: date-time + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/failed: + get: + operationId: getFailedOperations + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/audit/upload/{uploadId}: + get: + operationId: getUploadAuditHistory + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AuditEventDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/board/configs: + get: + operationId: getAllBoardConfigs + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/configs/{deviceId}: + get: + operationId: getAdminBoardConfig + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a device config as an administrator + tags: + - board-admin-controller + patch: + operationId: updateBoardConfig + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateBoardConfigRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + put: + operationId: saveBoardConfig + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/devices: + get: + operationId: listDevices + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DeviceSummary" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List every enrolled board device + tags: + - device-admin-controller + /api/admin/board/devices/enrollment-tokens: + post: + operationId: issueEnrollmentToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IssueEnrollmentTokenRequest" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/IssueEnrollmentTokenResponse" + description: Token issued; the plaintext is returned exactly once + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Mint a one-time token a new device can exchange for an identity + tags: + - device-admin-controller + /api/admin/board/devices/{deviceId}: + get: + operationId: getDevice + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceSummary" + description: Device found + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: No device with that id + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a single board device + tags: + - device-admin-controller + /api/admin/board/devices/{deviceId}/commands: + get: + operationId: listDeviceCommands + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/CommandView" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch the 50 most recent commands for a device + tags: + - device-admin-controller + post: + description: Returns as soon as the command is queued; delivery and execution + are reported asynchronously over the agent websocket. + operationId: issueDeviceCommand + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCommandRequest" + required: true + responses: + "202": + content: + application/json: + schema: + $ref: "#/components/schemas/CommandIssuedResponse" + description: Command accepted and queued for delivery + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Queue a command for a device + tags: + - device-admin-controller + /api/admin/board/devices/{deviceId}/revoke: + post: + description: "Closes any live agent session. Idempotent: re-revoking an already\ + \ revoked device is a no-op." + operationId: revokeDevice + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceSummary" + description: Device revoked + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: No device with that id + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke a device's enrollment + tags: + - device-admin-controller + /api/admin/board/events: + get: + operationId: getBoardEvents + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List every board event + tags: + - board-admin-controller + post: + operationId: createBoardEvent + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCalendarEventRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Create a board event + tags: + - board-admin-controller + /api/admin/board/events/by-audience: + get: + operationId: getEventsForAudience + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/events/{eventId}: + delete: + operationId: deleteEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getBoardEventById + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a single board event + tags: + - board-admin-controller + patch: + operationId: updateEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCalendarEventRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters: + get: + operationId: getAllPosters + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + post: + operationId: createPoster + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/CreatePosterRequest" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters/by-audience: + get: + operationId: getPostersForAudience + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters/{posterId}: + delete: + operationId: deletePoster + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getPosterById + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + patch: + operationId: updatePoster + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePosterRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/posters/{posterId}/image: + put: + operationId: updatePosterImage + parameters: + - in: path + name: posterId + required: true + schema: + type: string + format: uuid + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + required: + - image + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Poster" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/refresh: + post: + operationId: refreshSignboard + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/weekly-content: + get: + operationId: getAllWeeklyContent + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/weekly-content/year/{year}: + get: + operationId: getWeeklyContentByYear + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/weekly-content/{year}/{weekNumber}: + delete: + operationId: deleteWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getAdminWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch weekly content as an administrator + tags: + - board-admin-controller + put: + operationId: saveWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContentRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/create-event: + post: + operationId: createMediaEvent + parameters: + - in: query + name: eventName + required: true + schema: + type: string + - in: query + name: eventDate + required: true + schema: + type: string + format: date-time + - in: query + name: status + required: false + schema: + type: string + enum: + - UPCOMING + - ONGOING + - PAST + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Create a media event + tags: + - admin-controller + /api/admin/events: + get: + operationId: getAllEventsForAdmin + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MediaEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: "List every event, including unpublished ones" + tags: + - admin-controller + /api/admin/feature-upload/{uploadId}: + post: + operationId: featureUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/roles: + get: + operationId: getAvailableRoles + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: string + enum: + - USER + - ADMIN + - ROOT + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List the roles that can be assigned to a user + tags: + - admin-controller + /api/admin/upload/{uploadId}: + delete: + operationId: deleteUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + post: + operationId: approveUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/upload/{uploadId}/approval: + delete: + operationId: unapproveUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/upload/{uploadId}/featured: + delete: + operationId: unfeatureUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/uploads: + get: + operationId: getAdminUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: "Page through every upload, any approval state" + tags: + - admin-controller + /api/admin/uploads/approved: + get: + operationId: getApprovedUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/uploads/featured: + get: + operationId: getFeaturedUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/uploads/pending: + get: + operationId: getPendingUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageAdminUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/create: + post: + operationId: createUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/verify: + post: + operationId: verifyUser + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyUserRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/{userId}/add-role: + post: + operationId: addRoleToUser + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - USER + - ADMIN + - ROOT + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/user/{userId}/remove-role: + post: + operationId: removeRoleFromUser + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - USER + - ADMIN + - ROOT + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/admin/users: + get: + operationId: getAllUsers + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageUserInfoResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - admin-controller + /api/agent/enroll: + post: + description: Called once per device by the MusallahBoard agent. The returned + websocketUrl is persisted verbatim into the agent's config and never requested + again. + operationId: enrollAgent + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AgentEnrollRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AgentEnrollResponse" + description: Device enrolled; identity and websocket URL returned + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Public key malformed or unsupported + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Enrollment token invalid, expired, or already used" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Exchange a one-time enrollment token for a device identity + tags: + - agent-enrollment-controller + /api/auth/change-password: + post: + description: "Revokes every refresh token for the user, so all sessions must\ + \ sign in again." + operationId: changePassword + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ChangePasswordRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Password changed; all sessions revoked + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Current password incorrect or new password rejected + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Not signed in + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Change the signed-in user's password + tags: + - auth-controller + /api/auth/forgot-password: + post: + description: Always reports success so the response cannot be used to probe + which addresses are registered. + operationId: requestPasswordReset + parameters: + - in: query + name: email + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request accepted + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Email missing + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Send a password reset email + tags: + - auth-controller + /api/auth/logout: + post: + description: "Succeeds even when no token is supplied, so signing out is idempotent." + operationId: logout + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshRequest" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Session ended + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke a single refresh token + tags: + - auth-controller + /api/auth/logout-all-devices: + post: + operationId: logoutAllDevices + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: All sessions ended + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Not signed in + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke every refresh token for the signed-in user + tags: + - auth-controller + /api/auth/refresh-token: + post: + description: "Rotates the refresh token: the supplied one is revoked and a new\ + \ one returned." + operationId: refreshToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshResponse" + description: New access and refresh tokens issued + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Refresh token unknown, expired or already revoked" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Exchange a refresh token for a new access token + tags: + - auth-controller + /api/auth/reset-password: + post: + description: "The token and password travel in the request body, not the query\ + \ string: query strings are recorded in access logs, proxies and browser history." + operationId: resetPassword + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResetPasswordRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Password reset + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Token or password missing, invalid or expired" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Set a new password using a reset token + tags: + - auth-controller + /api/auth/signin: + post: + operationId: signIn + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/JwtResponse" + description: Authenticated; access and refresh tokens issued + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Bad credentials or disabled account + "429": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Too many failed attempts; account temporarily locked + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Authenticate with email and password + tags: + - auth-controller + /api/auth/signup: + post: + operationId: signUp + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Account created; verification email sent + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Validation failed or email already registered + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Register a new account + tags: + - auth-controller + /api/auth/validate-reset-token: + post: + description: "A POST despite being a read: the token is a credential, and only\ + \ a body keeps it out of access logs, proxy logs and browser history. Being\ + \ a POST also stops the result being cached by an intermediary." + operationId: validateResetToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ValidateResetTokenRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Token is valid + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Token missing, invalid or expired" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Check whether a password reset token is still usable + tags: + - auth-controller + /api/auth/validate-token: + get: + operationId: validateToken + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TokenValidationResponse" + description: Token valid; user returned + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Token missing, expired, or user no longer exists" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Check the current access token and return the user it belongs to + tags: + - auth-controller + /api/auth/verify-email: + post: + operationId: verifyEmail + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyEmailRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Email verified + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Missing, invalid or expired token" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Confirm an email address using the token from the verification email + tags: + - auth-controller + /api/events: + get: + operationId: getPublicEvents + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MediaEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: List publicly visible events + tags: + - events-controller + /api/events/{id}: + get: + operationId: getPublicEventById + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MediaEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch a single publicly visible event + tags: + - events-controller + /api/gallery: + get: + operationId: getGalleryUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageGalleryItemDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Page through approved gallery media + tags: + - gallery-controller + /api/gallery/event/{eventId}: + get: + operationId: getGalleryByEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageGalleryItemDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - gallery-controller + /api/musallah/config: + get: + operationId: getPublicBoardConfig + parameters: + - in: query + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch the config a kiosk should render + tags: + - musallah-board-controller + /api/musallah/events: + get: + operationId: getUpcomingEvents + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/events/range: + get: + operationId: getEventsInRange + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + - in: query + name: start + required: true + schema: + type: string + format: date-time + - in: query + name: end + required: true + schema: + type: string + format: date-time + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BoardEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/payload: + get: + operationId: getBoardPayload + parameters: + - in: query + name: deviceId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MusallahBoardPayload" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/posters: + get: + operationId: getActivePosterFrames + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FrameDefinition" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/weekly-content: + get: + operationId: getCurrentWeeklyContent + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - musallah-board-controller + /api/musallah/weekly-content/{year}/{weekNumber}: + get: + operationId: getPublicWeeklyContent + parameters: + - in: path + name: year + required: true + schema: + type: integer + format: int32 + - in: path + name: weekNumber + required: true + schema: + type: integer + format: int32 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WeeklyContent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Fetch weekly content for a kiosk + tags: + - musallah-board-controller + /api/upload/event/{eventId}: + get: + operationId: getUploadsByEvent + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageUploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/limits: + get: + operationId: getUploadLimits + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UploadLimitsResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/{eventId}/direct/complete: + post: + operationId: completeDirectUpload + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - in: query + name: objectKey + required: true + schema: + type: string + - in: query + name: filename + required: true + schema: + type: string + - in: query + name: contentType + required: true + schema: + type: string + - in: query + name: fileSize + required: true + schema: + type: integer + format: int64 + - in: query + name: instagramHandle + required: false + schema: + type: string + - in: query + name: description + required: false + schema: + type: string + - in: query + name: anon + required: false + schema: + type: boolean + default: false + - in: query + name: expectedSha256 + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UploadCompletionResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/{eventId}/direct/presign: + post: + operationId: generatePresignedUploadUrl + parameters: + - in: path + name: eventId + required: true + schema: + type: string + format: uuid + - in: query + name: filename + required: true + schema: + type: string + - in: query + name: contentType + required: true + schema: + type: string + - in: query + name: fileSize + required: true + schema: + type: integer + format: int64 + - in: query + name: expectedSha256 + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PresignedUploadResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/upload/{uploadId}: + get: + operationId: getUploadById + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UploadDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - file-upload-controller + /api/user/profile: + get: + operationId: getUserProfile + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfoResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + patch: + operationId: updateUserProfile + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateProfileRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfoResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + /api/user/stats: + get: + operationId: getUserStats + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UserStatsResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + /api/user/uploads: + get: + operationId: getUserUploads + parameters: + - description: Zero-based page index (0..N) + in: query + name: page + required: false + schema: + type: integer + default: 0 + minimum: 0 + - description: The size of the page to be returned + in: query + name: size + required: false + schema: + type: integer + default: 20 + minimum: 1 + - description: "Sorting criteria in the format: property,(asc|desc). Default\ + \ sort order is ascending. Multiple sort criteria are supported." + in: query + name: sort + required: false + schema: + type: array + items: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PageGalleryItemDto" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller + /api/user/uploads/{uploadId}: + delete: + operationId: deleteUserUpload + parameters: + - in: path + name: uploadId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - user-controller +components: + schemas: + AdminUploadDto: + type: object + properties: + anon: + type: boolean + approved: + type: boolean + contentType: + type: string + enum: + - image + - video + - audio + - document + createdDate: + type: string + format: date-time + displayName: + type: string + eventId: + type: string + format: uuid + eventName: + type: string + featured: + type: boolean + fileName: + type: string + fileUrl: + type: string + instagramHandle: + type: string + secureUrl: + type: string + thumbnailUrl: + type: string + uploadDescription: + type: string + uploadedBy: + type: string + format: uuid + uploaderEmail: + type: string + uploaderFirstName: + type: string + uploaderFullName: + type: string + uploaderLastName: + type: string + uploaderStudentNumber: + type: string + uuid: + type: string + format: uuid + AgentEnrollRequest: + type: object + properties: + agentVersion: + type: string + hardwareModel: + type: string + hostname: + type: string + minLength: 1 + publicKey: + type: string + minLength: 1 + token: + type: string + minLength: 1 + required: + - hostname + - publicKey + - token + AgentEnrollResponse: + type: object + properties: + deviceId: + type: string + format: uuid + websocketUrl: + type: string + AuditEventDto: + type: object + properties: + action: + type: string + enum: + - APPROVE_UPLOAD + - UNAPPROVE_UPLOAD + - DELETE_UPLOAD + - FEATURE_UPLOAD + - UNFEATURE_UPLOAD + - CREATE_EVENT + - UPDATE_EVENT + - DELETE_EVENT + - CREATE_CALENDAR_EVENT + - UPDATE_CALENDAR_EVENT + - DELETE_CALENDAR_EVENT + - CREATE_POSTER + - UPDATE_POSTER + - DELETE_POSTER + - PROMOTE_USER + - DEMOTE_USER + - DISABLE_USER + - ENABLE_USER + - VIEW_AUDIT_LOGS + - EXPORT_DATA + - SYSTEM_MAINTENANCE + - VERIFY_USER + - UNVERIFY_USER + - RESET_USER_PASSWORD + - TRIGGER_PASSWORD_RESET_EMAIL + - ADD_USER_ROLE + - REMOVE_USER_ROLE + - ADD_USER + - REMOVE_USER + - UPDATE_USER + adminEmail: + type: string + adminId: + type: string + format: uuid + adminName: + type: string + id: + type: string + format: uuid + ipAddress: + type: string + targetEntityId: + type: string + format: uuid + targetEntityType: + type: string + enum: + - USER + - UPLOAD + - MUSALLAH_BOARD + - EVENT + timestamp: + type: string + format: date-time + userAgent: + type: string + BoardEvent: + type: object + properties: + allDay: + type: boolean + audience: + type: string + enum: + - brothers + - sisters + - both + description: + type: string + endTime: + type: string + format: date-time + id: + type: string + format: uuid + location: + type: string + name: + type: string + startTime: + type: string + format: date-time + ChangePasswordRequest: + type: object + properties: + currentPassword: + type: string + maxLength: 256 + minLength: 6 + newPassword: + type: string + maxLength: 256 + minLength: 6 + required: + - currentPassword + - newPassword + CommandIssuedResponse: + type: object + properties: + commandId: + type: string + format: uuid + deviceId: + type: string + format: uuid + expiresAt: + type: string + format: date-time + issuedAt: + type: string + format: date-time + kind: + type: string + status: + type: string + enum: + - PENDING + - DELIVERED + - ACKED + - RUNNING + - SUCCEEDED + - FAILED + - TIMEOUT + - REJECTED + - EXPIRED + CommandView: + type: object + properties: + ackedAt: + type: string + format: date-time + deadlineMs: + type: integer + format: int32 + deliveredAt: + type: string + format: date-time + deviceId: + type: string + format: uuid + errorMessage: + type: string + expiresAt: + type: string + format: date-time + finishedAt: + type: string + format: date-time + id: + type: string + format: uuid + issuedAt: + type: string + format: date-time + issuedBy: + type: string + kind: + type: string + output: + $ref: "#/components/schemas/JsonNode" + payload: + $ref: "#/components/schemas/JsonNode" + startedAt: + type: string + format: date-time + status: + type: string + enum: + - PENDING + - DELIVERED + - ACKED + - RUNNING + - SUCCEEDED + - FAILED + - TIMEOUT + - REJECTED + - EXPIRED + CreateCalendarEventRequest: + type: object + properties: + allDay: + type: boolean + audience: + type: string + enum: + - brothers + - sisters + - both + description: + type: string + minLength: 1 + endEpochMs: + type: integer + format: int64 + location: + type: string + maxLength: 255 + minLength: 0 + name: + type: string + minLength: 1 + startEpochMs: + type: integer + format: int64 + required: + - audience + - description + - endEpochMs + - location + - name + - startEpochMs + CreatePosterRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + endTime: + type: string + format: date-time + imageFile: + type: string + format: binary + signupUrl: + type: string + startTime: + type: string + format: date-time + title: + type: string + minLength: 1 + required: + - audience + - endTime + - imageFile + - startTime + - title + DailyScheduleFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/EventView" + heading: + type: string + DeviceConfig: + type: object + properties: + darkModeAfterIsha: + type: boolean + enableScrollingMessage: + type: boolean + id: + type: string + format: uuid + location: + $ref: "#/components/schemas/Location" + scrollingMessages: + type: array + items: + type: string + socialUrl: + type: string + DeviceSummary: + type: object + properties: + agentVersion: + type: string + audience: + type: string + enum: + - brothers + - sisters + - both + displayName: + type: string + enrolledAt: + type: string + format: date-time + hardwareModel: + type: string + id: + type: string + format: uuid + lastHeartbeat: + type: string + format: date-time + lastSeenIp: + type: string + revokedAt: + type: string + format: date-time + EventListFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/EventView" + heading: + type: string + EventView: + type: object + properties: + allDay: + type: boolean + description: + type: string + endTime: + type: string + format: date-time + location: + type: string + name: + type: string + startTime: + type: string + format: date-time + FrameConfig: + discriminator: + propertyName: type + properties: + type: + type: string + required: + - type + FrameDefinition: + type: object + properties: + durationInSeconds: + type: integer + format: int32 + frameConfig: + oneOf: + - $ref: "#/components/schemas/DailyScheduleFrameConfig" + - $ref: "#/components/schemas/EventListFrameConfig" + - $ref: "#/components/schemas/IslamicQuoteFrameConfig" + - $ref: "#/components/schemas/JummahFrameConfig" + - $ref: "#/components/schemas/PosterFrameConfig" + frameType: + type: string + enum: + - poster + - event_list + - daily_schedule + - next_prayer + - jummah + - islamic_quote + priority: + type: integer + format: int32 + slot: + type: string + enum: + - PRIMARY + - TICKER + - SIDEBAR + - OVERLAY + GalleryItemDto: + type: object + properties: + author: + type: string + date: + type: string + event: + type: string + featured: + type: boolean + id: + type: string + src: + type: string + thumbnail: + type: string + title: + type: string + type: + type: string + IslamicQuote: + type: object + properties: + arabic: + type: string + id: + type: string + format: uuid + kind: + type: string + enum: + - VERSE + - HADITH + reference: + type: string + translation: + type: string + transliteration: + type: string + IslamicQuoteFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + arabic: + type: string + kind: + type: string + enum: + - VERSE + - HADITH + reference: + type: string + translation: + type: string + transliteration: + type: string + IssueCommandRequest: + type: object + properties: + deadlineMs: + type: integer + format: int32 + kind: + type: string + minLength: 1 + pattern: "[a-z]+\\.[a-z_]+" + payload: + $ref: "#/components/schemas/JsonNode" + ttlSeconds: + type: integer + format: int32 + required: + - kind + IssueEnrollmentTokenRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + displayName: + type: string + minLength: 1 + expiresInMinutes: + type: integer + format: int32 + required: + - audience + - displayName + IssueEnrollmentTokenResponse: + type: object + properties: + expiresAt: + type: string + format: date-time + token: + type: string + tokenId: + type: string + format: uuid + JsonNode: {} + JummahFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + prayers: + type: array + items: + $ref: "#/components/schemas/JummahSlot" + JummahPrayer: + type: object + properties: + id: + type: string + format: uuid + khatib: + type: string + prayerTime: + type: string + room: + type: string + JummahSlot: + type: object + properties: + khatib: + type: string + prayerTime: + type: string + room: + type: string + JwtResponse: + type: object + properties: + email: + type: string + firstName: + type: string + id: + type: string + format: uuid + lastName: + type: string + refreshToken: + type: string + roles: + type: array + items: + type: string + token: + type: string + type: + type: string + Location: + type: object + properties: + city: + type: string + country: + type: string + latitude: + type: number + format: double + longitude: + type: number + format: double + method: + type: string + enum: + - KARACHI + - ISNA + - MWL + - MAKKAH + - EGYPT + - TEHRAN + - GULF + - KUWAIT + - QATAR + - SINGAPORE + - FRANCE + - TURKEY + - RUSSIA + - DUBAI + timezone: + type: string + LoginRequest: + type: object + properties: + email: + type: string + minLength: 1 + password: + type: string + minLength: 1 + required: + - email + - password + MediaEvent: + type: object + properties: + date: + type: string + format: date-time + id: + type: string + format: uuid + name: + type: string + status: + type: string + enum: + - UPCOMING + - ONGOING + - PAST + MessageResponse: + type: object + properties: + message: + type: string + MusallahBoardPayload: + type: object + properties: + deviceConfig: + $ref: "#/components/schemas/DeviceConfig" + frames: + type: array + items: + $ref: "#/components/schemas/FrameDefinition" + weather: + $ref: "#/components/schemas/JsonNode" + PageAdminUploadDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/AdminUploadDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageAuditEventDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/AuditEventDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageGalleryItemDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/GalleryItemDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageUploadDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/UploadDto" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + PageUserInfoResponse: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/UserInfoResponse" + empty: + type: boolean + first: + type: boolean + last: + type: boolean + number: + type: integer + format: int32 + numberOfElements: + type: integer + format: int32 + pageable: + $ref: "#/components/schemas/Pageable" + size: + type: integer + format: int32 + sort: + $ref: "#/components/schemas/SortObject" + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + Pageable: + type: object + properties: + page: + type: integer + format: int32 + minimum: 0 + size: + type: integer + format: int32 + minimum: 1 + sort: + type: array + items: + type: string + Poster: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + endTime: + type: string + format: date-time + id: + type: string + format: uuid + image: + type: string + signupUrl: + type: string + startTime: + type: string + format: date-time + title: + type: string + PosterFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + posterUrl: + type: string + signupUrl: + type: string + title: + type: string + PresignedUploadResponse: + type: object + properties: + contentType: + type: string + eventId: + type: string + format: uuid + expectedSha256: + type: string + expiresInMinutes: + type: integer + format: int32 + method: + type: string + objectKey: + type: string + uploadUrl: + type: string + QuoteEntry: + type: object + properties: + arabic: + type: string + kind: + type: string + enum: + - VERSE + - HADITH + reference: + type: string + translation: + type: string + transliteration: + type: string + ResetPasswordRequest: + type: object + properties: + newPassword: + type: string + maxLength: 256 + minLength: 6 + token: + type: string + minLength: 1 + required: + - newPassword + - token + SignupRequest: + type: object + properties: + email: + type: string + maxLength: 50 + minLength: 0 + pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]*\\.utoronto\\.ca$" + firstName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + lastName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + password: + type: string + maxLength: 40 + minLength: 6 + studentNumber: + type: string + maxLength: 10 + minLength: 0 + required: + - email + - firstName + - lastName + - password + - studentNumber + SortObject: + type: object + properties: + empty: + type: boolean + sorted: + type: boolean + unsorted: + type: boolean + TokenRefreshRequest: + type: object + properties: + refreshToken: + type: string + minLength: 1 + required: + - refreshToken + TokenRefreshResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + tokenType: + type: string + TokenValidationResponse: + type: object + properties: + email: + type: string + firstName: + type: string + id: + type: string + format: uuid + lastName: + type: string + roles: + type: array + items: + type: string + valid: + type: boolean + verified: + type: boolean + UpdateBoardConfigRequest: + type: object + properties: + darkModeAfterIsha: + type: boolean + enableScrollingMessage: + type: boolean + location: + $ref: "#/components/schemas/Location" + scrollingMessages: + type: array + items: + type: string + socialUrl: + type: string + UpdateCalendarEventRequest: + type: object + properties: + allDay: + type: boolean + audience: + type: string + enum: + - brothers + - sisters + - both + description: + type: string + endTime: + type: string + format: date-time + location: + type: string + name: + type: string + startTime: + type: string + format: date-time + UpdatePosterRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + endTime: + type: string + format: date-time + signupUrl: + type: string + startTime: + type: string + format: date-time + title: + type: string + UpdateProfileRequest: + type: object + properties: + firstName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + lastName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + studentNumber: + type: string + maxLength: 10 + minLength: 0 + UploadCompletionResponse: + type: object + properties: + eventId: + type: string + format: uuid + fileSize: + type: integer + format: int64 + objectKey: + type: string + uploadId: + type: string + format: uuid + verified: + type: boolean + UploadDto: + type: object + properties: + anon: + type: boolean + approved: + type: boolean + contentType: + type: string + enum: + - image + - video + - audio + - document + createdDate: + type: string + format: date-time + eventId: + type: string + format: uuid + eventName: + type: string + featured: + type: boolean + fileName: + type: string + fileUrl: + type: string + instagramHandle: + type: string + thumbnailUrl: + type: string + uploadDescription: + type: string + uploadedBy: + type: string + format: uuid + uuid: + type: string + format: uuid + UploadLimitsResponse: + type: object + properties: + allowedContentTypes: + type: array + items: + type: string + dailyLimit: + type: integer + format: int32 + maxSizeBytes: + type: integer + format: int64 + maxSizeMB: + type: integer + format: int64 + role: + type: string + uploadsRemaining: + type: integer + format: int64 + uploadsToday: + type: integer + format: int64 + videoMaxDurationSeconds: + type: integer + format: int32 + UserInfoResponse: + type: object + properties: + email: + type: string + firstName: + type: string + id: + type: string + format: uuid + lastName: + type: string + roles: + type: array + items: + type: string + enum: + - USER + - ADMIN + - ROOT + uniqueItems: true + studentNumber: + type: string + verified: + type: boolean + UserStatsResponse: + type: object + properties: + approvedUploads: + type: integer + format: int32 + featuredUploads: + type: integer + format: int32 + pendingUploads: + type: integer + format: int32 + totalUploads: + type: integer + format: int32 + ValidateResetTokenRequest: + type: object + properties: + token: + type: string + minLength: 1 + required: + - token + VerifyEmailRequest: + type: object + properties: + token: + type: string + minLength: 1 + required: + - token + VerifyUserRequest: + type: object + properties: + userId: + type: string + format: uuid + required: + - userId + WeeklyContent: + type: object + properties: + id: + type: string + format: uuid + jummahPrayers: + type: array + items: + $ref: "#/components/schemas/JummahPrayer" + quotes: + type: array + items: + $ref: "#/components/schemas/IslamicQuote" + weekNumber: + type: integer + format: int32 + year: + type: integer + format: int32 + WeeklyContentRequest: + type: object + properties: + jummahPrayers: + type: array + items: + $ref: "#/components/schemas/JummahSlot" + quotes: + type: array + items: + $ref: "#/components/schemas/QuoteEntry" + securitySchemes: + bearerAuth: + bearerFormat: JWT + name: bearerAuth + scheme: bearer + type: http diff --git a/pom.xml b/pom.xml index bdc4bf1..d15005a 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,12 @@ 21 + 2.8.8 + 1.4 + + 8089 + 9011 @@ -159,7 +165,7 @@ org.springdoc springdoc-openapi-starter-webmvc-ui - 2.8.8 + ${springdoc.version} @@ -179,11 +185,6 @@ runtime - - com.ibrasoft - tcketmanage-core - 0.0.1-SNAPSHOT - @@ -222,4 +223,76 @@ - \ No newline at end of file + + + + openapi + + + + org.springframework.boot + spring-boot-maven-plugin + + + pre-integration-test + + start + + + dev + ${openapi.gen.jmxPort} + + --server.port=${openapi.gen.port} + + + true + + + + + post-integration-test + + stop + + + ${openapi.gen.jmxPort} + + + + + + org.springdoc + springdoc-openapi-maven-plugin + ${springdoc-maven-plugin.version} + + + generate-openapi-spec + integration-test + + generate + + + + + http://localhost:${openapi.gen.port}/v3/api-docs.yaml + openapi.yaml + ${project.basedir} + + + + + + + + diff --git a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java index 1300259..9d0631b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java @@ -4,20 +4,69 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; +import com.ibrasoft.lensbridge.security.CurrentUser; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.utils.SpringDocUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + @Configuration public class OpenApiConfig { + static { + // Workaround for @CurrentUser being an argument, which springdoc cannot infer + SpringDocUtils.getConfig().addAnnotationsToIgnore(CurrentUser.class); + + // Likewise Pageable, which is documented as a single object-typed query + // parameter instead of the page/size/sort trio the resolver actually reads. + SpringDocUtils.getConfig().replaceWithClass( + org.springframework.data.domain.Pageable.class, + org.springdoc.core.converters.models.Pageable.class); + } + + @Value("${lensbridge.app.version:unknown}") + private String appVersion; + + @Bean + public OpenApiCustomizer defaultErrorResponseCustomizer() { + return openApi -> { + Schema messageResponse = new Schema<>() + .$ref("#/components/schemas/MessageResponse"); + ApiResponse fallback = new ApiResponse() + .description("Request failed; body carries a human-readable message") + .content(new Content().addMediaType( + org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + new MediaType().schema(messageResponse))); + + openApi.getPaths().values().stream() + .flatMap(pathItem -> pathItem.readOperations().stream()) + .forEach(operation -> { + ApiResponses responses = operation.getResponses(); + if (responses != null && responses.getDefault() == null) { + responses.addApiResponse("default", fallback); + } + }); + }; + } + @Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info() .title("LensBridge API") - .version("1.0.0") + .version(appVersion) .description("LensBridge backend — media upload and MusallahBoard management")) + .servers(List.of(new Server().url("/"))) .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) .components(new Components() .addSecuritySchemes("bearerAuth", new SecurityScheme() From 34d696d3b0a61df057107a71587fd27fc7f67af4 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sun, 2 Aug 2026 21:12:17 -0400 Subject: [PATCH 33/52] Massive changes to support new OpenAPI configurations + new features and bug fixes --- .../controller/AdminController.java | 66 +++---- .../controller/AgentEnrollmentController.java | 27 ++- .../lensbridge/controller/AuthController.java | 149 ++++++++++++---- .../controller/BoardAdminController.java | 31 ++-- .../controller/DeviceAdminController.java | 48 +++++- .../controller/EventsController.java | 3 + .../controller/FileUploadController.java | 6 +- .../controller/GalleryController.java | 7 +- .../controller/MusallahBoardController.java | 3 + .../lensbridge/controller/UserController.java | 3 +- .../auth/request/ResetPasswordRequest.java | 24 +++ .../request/ValidateResetTokenRequest.java | 18 ++ .../dto/auth/request/VerifyEmailRequest.java | 11 ++ .../dto/auth/request/VerifyUserRequest.java | 13 ++ .../board/request/CreatePosterRequest.java | 9 + .../request/UpdateBoardConfigRequest.java | 6 + .../board/request/UpdatePosterRequest.java | 7 + .../exception/GlobalExceptionHandler.java | 16 +- .../lensbridge/model/board/Audience.java | 14 ++ .../lensbridge/model/board/Poster.java | 6 + .../model/board/embedded/DeviceConfig.java | 12 ++ .../model/board/frames/PosterFrameConfig.java | 7 + .../security/WebSecurityConfig.java | 4 +- .../lensbridge/service/BoardService.java | 5 + .../lensbridge/service/PosterService.java | 16 +- .../transformer/PosterFrameTransformer.java | 1 + .../AuthControllerResetPasswordTest.java | 128 ++++++++++++++ .../AuthControllerValidateResetTokenTest.java | 115 ++++++++++++ .../model/board/AudienceJsonTest.java | 53 ++++++ .../lensbridge/service/PosterServiceTest.java | 17 +- tools/check_openapi.py | 163 ++++++++++++++++++ 31 files changed, 873 insertions(+), 115 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/AudienceJsonTest.java create mode 100644 tools/check_openapi.py diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 93db552..37e162b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -4,6 +4,7 @@ import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.VerifyUserRequest; import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; @@ -14,7 +15,9 @@ import com.ibrasoft.lensbridge.service.EventsService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; +import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -28,10 +31,10 @@ import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api/admin") @@ -45,19 +48,19 @@ public class AdminController { private final AdminAuditService auditService; private final UserService userService; - private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { + private ResponseEntity executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(uploadId); this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "Upload", uploadId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } - private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { + private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { serviceAction.accept(userId); this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "User", userId, request.getRemoteAddr()); return ResponseEntity.ok(new MessageResponse(successMessage)); } - private ResponseEntity executeCreationAction(T input, HttpServletRequest request, + private ResponseEntity executeCreationAction(T input, HttpServletRequest request, Function serviceAction, AuditAction auditAction, String entityType, Function idExtractor, String successMessage) { @@ -68,7 +71,8 @@ private ResponseEntity executeCreationAction(T input, HttpServletReque } @PostMapping("/create-event") - public ResponseEntity createEvent(@RequestParam("eventName") String eventName, + @Operation(operationId = "createMediaEvent", summary = "Create a media event") + public ResponseEntity createEvent(@RequestParam("eventName") String eventName, @RequestParam(value = "eventDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant eventDate, @RequestParam(value = "status", required = false) EventStatus status, HttpServletRequest request) { @@ -82,65 +86,67 @@ public ResponseEntity createEvent(@RequestParam("eventName") String eventName // Upload Management Operations @PostMapping("/upload/{uploadId}") - public ResponseEntity approveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + public ResponseEntity approveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { return executeUploadAction(uploadId, request, uploadService::approveUpload, AuditAction.APPROVE_UPLOAD, "Upload approved successfully"); } @DeleteMapping("/upload/{uploadId}") - public ResponseEntity deleteUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + public ResponseEntity deleteUpload(@PathVariable UUID uploadId, HttpServletRequest request) { return executeUploadAction(uploadId, request, uploadService::deleteUpload, AuditAction.DELETE_UPLOAD, "Upload deleted successfully"); } @PostMapping("/feature-upload/{uploadId}") - public ResponseEntity featureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + public ResponseEntity featureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { return executeUploadAction(uploadId, request, uploadService::featureUpload, AuditAction.FEATURE_UPLOAD, "Upload featured successfully"); } @DeleteMapping("/upload/{uploadId}/approval") - public ResponseEntity unapproveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + public ResponseEntity unapproveUpload(@PathVariable UUID uploadId, HttpServletRequest request) { return executeUploadAction(uploadId, request, uploadService::unapproveUpload, AuditAction.UNAPPROVE_UPLOAD, "Upload unapproved successfully"); } @DeleteMapping("/upload/{uploadId}/featured") - public ResponseEntity unfeatureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { + public ResponseEntity unfeatureUpload(@PathVariable UUID uploadId, HttpServletRequest request) { return executeUploadAction(uploadId, request, uploadService::unfeatureUpload, AuditAction.UNFEATURE_UPLOAD, "Upload unfeatured successfully"); } // Data Retrieval Operations @GetMapping("/uploads") - public ResponseEntity> getAllUploads(Pageable pageable) { + @Operation(operationId = "getAdminUploads", summary = "Page through every upload, any approval state") + public ResponseEntity> getAllUploads(@ParameterObject Pageable pageable) { return ResponseEntity.ok(uploadService.getAllUploadsForAdmin(pageable)); } @GetMapping("/uploads/pending") - public ResponseEntity> getPendingUploads(Pageable pageable) { + public ResponseEntity> getPendingUploads(@ParameterObject Pageable pageable) { return ResponseEntity.ok(uploadService.getUploadsByApprovalStatus(false, pageable)); } @GetMapping("/uploads/approved") - public ResponseEntity> getApprovedUploads(Pageable pageable) { + public ResponseEntity> getApprovedUploads(@ParameterObject Pageable pageable) { return ResponseEntity.ok(uploadService.getUploadsByApprovalStatus(true, pageable)); } @GetMapping("/uploads/featured") - public ResponseEntity> getFeaturedUploads(Pageable pageable) { + public ResponseEntity> getFeaturedUploads(@ParameterObject Pageable pageable) { return ResponseEntity.ok(uploadService.getUploadsByFeaturedStatus(true, pageable)); } + @Operation(operationId = "getAllEventsForAdmin", summary = "List every event, including unpublished ones") @GetMapping("/events") - public ResponseEntity getAllEvents() { + public ResponseEntity> getAllEvents() { return ResponseEntity.ok(eventsService.getAllEvents()); } @GetMapping("/users") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity> getAllUsers(Pageable pageable) { + public ResponseEntity> getAllUsers(@ParameterObject Pageable pageable) { return ResponseEntity.ok(userService.getAllUsers(pageable)); } @PostMapping("/user/{userId}/add-role") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { + public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, (id) -> userService.addRole(id, role), AuditAction.ADD_USER_ROLE, @@ -149,7 +155,7 @@ public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody R @PostMapping("/user/{userId}/remove-role") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { + public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, (id) -> userService.removeRole(id, role), AuditAction.REMOVE_USER_ROLE, @@ -158,15 +164,15 @@ public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestB @PostMapping("/user/verify") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity verifyUser(@RequestBody Map payload, HttpServletRequest request) { - return executeUserAction(payload.get("userId"), request, + public ResponseEntity verifyUser(@Valid @RequestBody VerifyUserRequest payload, HttpServletRequest request) { + return executeUserAction(payload.getUserId(), request, userService::verifyDirectly, AuditAction.VERIFY_USER, "User verified successfully"); } @PostMapping("/user/create") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, HttpServletRequest request) { + public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, HttpServletRequest request) { return executeCreationAction(signUpRequest, request, (signupRequest) -> { User newUser = userService.createUser(signupRequest, false); @@ -180,20 +186,21 @@ public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, Ht "User created successfully: " + signUpRequest.getEmail()); } + @Operation(operationId = "getAvailableRoles", summary = "List the roles that can be assigned to a user") @GetMapping("/roles") @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity getAvailableRoles() { - return ResponseEntity.ok(Role.values()); + public ResponseEntity> getAvailableRoles() { + return ResponseEntity.ok(List.of(Role.values())); } // Audit Reporting Endpoints @GetMapping("/audit") - public ResponseEntity> getAuditEvents(Pageable pageable) { + public ResponseEntity> getAuditEvents(@ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getAllAuditEvents(pageable)); } @GetMapping("/audit/failed") - public ResponseEntity> getFailedOperations(Pageable pageable) { + public ResponseEntity> getFailedOperations(@ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getFailedOperations(pageable)); } @@ -203,18 +210,19 @@ public ResponseEntity> getUploadAuditHistory(@PathVariable U } @GetMapping("/audit/action/{action}") - public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, Pageable pageable) { + public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, @ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getAuditEventsByAction(action, pageable)); } @GetMapping("/audit/daterange") - public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, Pageable pageable) { + public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, @ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getAuditEventsByDateRange(start, end, pageable)); } + @Operation(operationId = "getAvailableAuditActions", summary = "List the audit action types available for filtering") @GetMapping("/audit/actions") - public ResponseEntity getAvailableActions() { - return ResponseEntity.ok(AuditAction.values()); + public ResponseEntity> getAvailableActions() { + return ResponseEntity.ok(List.of(AuditAction.values())); } private String getCurrentUserEmail() { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java index 58b7fe4..f51708f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -3,8 +3,14 @@ import com.ibrasoft.lensbridge.dto.board.request.AgentEnrollRequest; import com.ibrasoft.lensbridge.dto.board.response.AgentEnrollResponse; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -34,8 +40,19 @@ public class AgentEnrollmentController { @Value("${musallahboard.agent.websocketUrl}") private String websocketUrl; + @Operation(operationId = "enrollAgent", + summary = "Exchange a one-time enrollment token for a device identity", + description = "Called once per device by the MusallahBoard agent. The returned websocketUrl is " + + "persisted verbatim into the agent's config and never requested again.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Device enrolled; identity and websocket URL returned"), + @ApiResponse(responseCode = "400", description = "Public key malformed or unsupported", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "401", description = "Enrollment token invalid, expired, or already used", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/enroll") - public ResponseEntity enroll(@Valid @RequestBody AgentEnrollRequest request, + public ResponseEntity enroll(@Valid @RequestBody AgentEnrollRequest request, HttpServletRequest httpRequest) { String remoteIp = resolveClientIp(httpRequest); @@ -53,10 +70,10 @@ public ResponseEntity enroll(@Valid @RequestBody AgentEnrollRequest request, .deviceId(ok.device().getId()) .websocketUrl(websocketUrl) .build()); - case Outcome.InvalidPublicKey bad -> ResponseEntity.badRequest() - .body(new MessageResponse("Invalid public key: " + bad.reason())); - case Outcome.InvalidToken ignored -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new MessageResponse("Enrollment token is invalid, expired, or already used")); + case Outcome.InvalidPublicKey bad -> throw new ApiResponseException(HttpStatus.BAD_REQUEST, + new MessageResponse("Invalid public key: " + bad.reason())); + case Outcome.InvalidToken ignored -> throw new ApiResponseException(HttpStatus.UNAUTHORIZED, + new MessageResponse("Enrollment token is invalid, expired, or already used")); }; } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index 57aac9f..9492a7a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -5,6 +5,11 @@ import java.util.UUID; import java.util.stream.Collectors; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -22,12 +27,16 @@ import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; import com.ibrasoft.lensbridge.dto.auth.request.LoginRequest; +import com.ibrasoft.lensbridge.dto.auth.request.ResetPasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.ValidateResetTokenRequest; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.TokenRefreshRequest; +import com.ibrasoft.lensbridge.dto.auth.request.VerifyEmailRequest; import com.ibrasoft.lensbridge.dto.auth.response.JwtResponse; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.dto.auth.response.TokenRefreshResponse; import com.ibrasoft.lensbridge.dto.auth.response.TokenValidationResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.exception.RefreshTokenException; import com.ibrasoft.lensbridge.model.auth.RefreshToken; import com.ibrasoft.lensbridge.model.auth.Role; @@ -50,14 +59,22 @@ public class AuthController { private final LoginAttemptService loginAttemptService; private final RefreshTokenService refreshTokenService; + @Operation(operationId = "signIn", summary = "Authenticate with email and password") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Authenticated; access and refresh tokens issued"), + @ApiResponse(responseCode = "401", description = "Bad credentials or disabled account", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "429", description = "Too many failed attempts; account temporarily locked", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/signin") - public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest, + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest, HttpServletRequest request) { String clientKey = loginRequest.getEmail(); if (loginAttemptService.isBlocked(clientKey)) { - return ResponseEntity.status(429) - .body(new MessageResponse("Account temporarily locked due to too many failed login attempts. Please try again later.")); + throw new ApiResponseException(HttpStatus.TOO_MANY_REQUESTS, + new MessageResponse("Account temporarily locked due to too many failed login attempts. Please try again later.")); } try { @@ -92,62 +109,113 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login } } + @Operation(operationId = "signUp", summary = "Register a new account") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Account created; verification email sent"), + @ApiResponse(responseCode = "400", description = "Validation failed or email already registered", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/signup") - public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { + public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { userService.createUser(signUpRequest); return ResponseEntity.ok(new MessageResponse("User registered successfully! Please check your email to verify your account.")); } + @Operation(operationId = "verifyEmail", summary = "Confirm an email address using the token from the verification email") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Email verified"), + @ApiResponse(responseCode = "400", description = "Missing, invalid or expired token", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/verify-email") - public ResponseEntity verifyEmail(@RequestBody java.util.Map payload) { - String token = payload.get("token"); - if (token == null || token.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Invalid verification token.")); - } - userService.verifyUserEmail(token); + public ResponseEntity verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + userService.verifyUserEmail(request.getToken()); return ResponseEntity.ok(new MessageResponse("Email verified successfully!")); } + @Operation(operationId = "requestPasswordReset", + summary = "Send a password reset email", + description = "Always reports success so the response cannot be used to probe which addresses are registered.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Request accepted"), + @ApiResponse(responseCode = "400", description = "Email missing", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/forgot-password") - public ResponseEntity requestPasswordReset(@RequestParam String email) { + public ResponseEntity requestPasswordReset(@RequestParam String email) { if (email == null || email.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Email is required.")); + throw new IllegalArgumentException("Email is required."); } userService.requestPasswordReset(email); return ResponseEntity.ok(new MessageResponse("If an account with that email exists, a password reset email has been sent.")); } - @GetMapping("/validate-reset-token") - public ResponseEntity validateResetToken(@RequestParam String token) { - if (token == null || token.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Token is required.")); - } - if (userService.validateResetToken(token)) { - return ResponseEntity.ok(new MessageResponse("Token is valid.")); + @Operation(operationId = "validateResetToken", + summary = "Check whether a password reset token is still usable", + description = "A POST despite being a read: the token is a credential, and only a body keeps it " + + "out of access logs, proxy logs and browser history. Being a POST also stops the " + + "result being cached by an intermediary.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Token is valid"), + @ApiResponse(responseCode = "400", description = "Token missing, invalid or expired", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @PostMapping("/validate-reset-token") + public ResponseEntity validateResetToken(@Valid @RequestBody ValidateResetTokenRequest request) { + // Presence is enforced by bean validation; GlobalExceptionHandler renders + // both that and the rejection below as a MessageResponse. + if (!userService.validateResetToken(request.getToken())) { + throw new IllegalArgumentException("Invalid or expired token."); } - return ResponseEntity.badRequest().body(new MessageResponse("Invalid or expired token.")); + return ResponseEntity.ok(new MessageResponse("Token is valid.")); } + @Operation(operationId = "resetPassword", + summary = "Set a new password using a reset token", + description = "The token and password travel in the request body, not the query string: " + + "query strings are recorded in access logs, proxies and browser history.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Password reset"), + @ApiResponse(responseCode = "400", description = "Token or password missing, invalid or expired", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/reset-password") - public ResponseEntity resetPassword(@RequestParam String token, @RequestParam String newPassword) { - if (token == null || token.isEmpty() || newPassword == null || newPassword.isEmpty()) { - return ResponseEntity.badRequest().body(new MessageResponse("Token and new password are required.")); - } - userService.resetPassword(token, newPassword); + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + // Presence and length are enforced by bean validation; MethodArgumentNotValidException + // is translated to a MessageResponse by GlobalExceptionHandler. + userService.resetPassword(request.getToken(), request.getNewPassword()); return ResponseEntity.ok(new MessageResponse("Password reset successfully.")); } + @Operation(operationId = "changePassword", + summary = "Change the signed-in user's password", + description = "Revokes every refresh token for the user, so all sessions must sign in again.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Password changed; all sessions revoked"), + @ApiResponse(responseCode = "400", description = "Current password incorrect or new password rejected", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "403", description = "Not signed in", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/change-password") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest, + public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest, @CurrentUser User user) { userService.changePassword(user, changePasswordRequest); refreshTokenService.revokeAllUserTokens(user.getId()); return ResponseEntity.ok(new MessageResponse("Password changed successfully. Please log in again.")); } + @Operation(operationId = "refreshToken", + summary = "Exchange a refresh token for a new access token", + description = "Rotates the refresh token: the supplied one is revoked and a new one returned.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "New access and refresh tokens issued"), + @ApiResponse(responseCode = "401", description = "Refresh token unknown, expired or already revoked", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/refresh-token") - public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest request) { + public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest request) { String requestRefreshToken = request.getRefreshToken(); RefreshToken refreshToken = refreshTokenService.findByToken(requestRefreshToken) @@ -171,29 +239,46 @@ public ResponseEntity refreshToken(@Valid @RequestBody TokenRefreshRequest re return ResponseEntity.ok(new TokenRefreshResponse(newAccessToken, newRefreshToken.getToken())); } + @Operation(operationId = "logout", + summary = "Revoke a single refresh token", + description = "Succeeds even when no token is supplied, so signing out is idempotent.") + @ApiResponse(responseCode = "200", description = "Session ended") @PostMapping("/logout") - public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { + public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { if (request != null && request.getRefreshToken() != null) { refreshTokenService.revokeRefreshToken(request.getRefreshToken()); } return ResponseEntity.ok(new MessageResponse("Logged out successfully")); } + @Operation(operationId = "logoutAllDevices", summary = "Revoke every refresh token for the signed-in user") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "All sessions ended"), + @ApiResponse(responseCode = "403", description = "Not signed in", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/logout-all-devices") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity logoutAllDevices(@CurrentUser User user) { + public ResponseEntity logoutAllDevices(@CurrentUser User user) { refreshTokenService.revokeAllUserTokens(user.getId()); return ResponseEntity.ok(new MessageResponse("Logged out from all devices successfully")); } + @Operation(operationId = "validateToken", + summary = "Check the current access token and return the user it belongs to") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Token valid; user returned"), + @ApiResponse(responseCode = "401", description = "Token missing, expired, or user no longer exists", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @GetMapping("/validate-token") - public ResponseEntity validateToken(Authentication authentication) { + public ResponseEntity validateToken(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(401).body(new MessageResponse("Invalid or expired token")); + throw new ApiResponseException(HttpStatus.UNAUTHORIZED, new MessageResponse("Invalid or expired token")); } User user = userService.findByEmail(authentication.getName()).orElse(null); if (user == null) { - return ResponseEntity.status(401).body(new MessageResponse("User not found")); + throw new ApiResponseException(HttpStatus.UNAUTHORIZED, new MessageResponse("User not found")); } return ResponseEntity.ok(new TokenValidationResponse( true, diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index efb17c7..438698c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -35,6 +35,7 @@ import java.time.Instant; import java.util.List; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; /** * Admin controller for managing board content (posters, calendar events, board config, weekly content). @@ -62,6 +63,7 @@ public ResponseEntity> getAllBoardConfigs() { } @GetMapping("/configs/{deviceId}") + @Operation(operationId = "getAdminBoardConfig", summary = "Fetch a device config as an administrator") public ResponseEntity getBoardConfig(@PathVariable UUID deviceId) { log.debug("Admin fetching board config for device: {}", deviceId); return ResponseEntity.ok(boardService.getBoardConfigOrThrow(deviceId)); @@ -104,6 +106,7 @@ public ResponseEntity> getWeeklyContentByYear(@PathVariable } @GetMapping("/weekly-content/{year}/{weekNumber}") + @Operation(operationId = "getAdminWeeklyContent", summary = "Fetch weekly content as an administrator") public ResponseEntity getWeeklyContent( @PathVariable int year, @PathVariable int weekNumber) { @@ -156,25 +159,18 @@ public ResponseEntity getPosterById(@PathVariable UUID posterId) { @PostMapping(value = "/posters", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createPoster( - @RequestParam("title") String title, - @RequestParam("duration") int duration, - @RequestParam("startTime") Instant startTime, - @RequestParam("endTime") Instant endTime, - @RequestParam("audience") Audience audience, - @RequestParam("image") MultipartFile imageFile, + // @ModelAttribute, not @RequestParam: on a complex type @RequestParam + // makes Spring look for one parameter literally named "createRequest" + // and convert it, which no converter can do. Binding a multipart form + // field-by-field onto a DTO is what @ModelAttribute is for, and it is + // also what makes springdoc document the individual fields instead of + // a single opaque object. + @ModelAttribute @Valid CreatePosterRequest createRequest, HttpServletRequest request) { - log.info("Admin creating new poster: title={}", title); - - CreatePosterRequest createRequest = CreatePosterRequest.builder() - .title(title) - .duration(duration) - .startTime(startTime) - .endTime(endTime) - .audience(audience) - .build(); + log.info("Admin creating new poster: title={}", createRequest.getTitle()); - Poster response = posterService.createPoster(createRequest, imageFile); + Poster response = posterService.createPoster(createRequest); auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_POSTER, "Poster", response.getId(), request.getRemoteAddr()); @@ -225,6 +221,7 @@ public ResponseEntity deletePoster( // ==================== Calendar Event Endpoints ==================== @GetMapping("/events") + @Operation(operationId = "getBoardEvents", summary = "List every board event") public ResponseEntity> getAllEvents() { log.debug("Admin fetching all calendar events"); return ResponseEntity.ok(boardService.getAllEvents()); @@ -237,12 +234,14 @@ public ResponseEntity> getEventsForAudience(@RequestParam Audie } @GetMapping("/events/{eventId}") + @Operation(operationId = "getBoardEventById", summary = "Fetch a single board event") public ResponseEntity getEventById(@PathVariable UUID eventId) { log.debug("Admin fetching calendar event: {}", eventId); return ResponseEntity.ok(boardService.getEventById(eventId)); } @PostMapping("/events") + @Operation(operationId = "createBoardEvent", summary = "Create a board event") public ResponseEntity createEvent( @Valid @RequestBody CreateCalendarEventRequest createRequest, HttpServletRequest request) { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index 7ff04c9..59d463b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -15,7 +15,13 @@ import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; +import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService.Issued; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -46,6 +52,7 @@ public class DeviceAdminController { private final ObjectMapper objectMapper; private final AgentSessionRegistry sessionRegistry; + @Operation(operationId = "listDevices", summary = "List every enrolled board device") @GetMapping public ResponseEntity> list() { return ResponseEntity.ok( @@ -53,14 +60,23 @@ public ResponseEntity> list() { ); } + @Operation(operationId = "getDevice", summary = "Fetch a single board device") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Device found"), + @ApiResponse(responseCode = "404", description = "No device with that id", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @GetMapping("/{deviceId}") - public ResponseEntity get(@PathVariable UUID deviceId) { - Optional found = deviceRepository.findById(deviceId); - return found.>map(d -> ResponseEntity.ok(DeviceSummary.of(d))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new MessageResponse("Device not found"))); + public ResponseEntity get(@PathVariable UUID deviceId) { + return deviceRepository.findById(deviceId) + .map(d -> ResponseEntity.ok(DeviceSummary.of(d))) + .orElseThrow(() -> new ApiResponseException(HttpStatus.NOT_FOUND, + new MessageResponse("Device not found"))); } + @Operation(operationId = "issueEnrollmentToken", + summary = "Mint a one-time token a new device can exchange for an identity") + @ApiResponse(responseCode = "201", description = "Token issued; the plaintext is returned exactly once") @PostMapping("/enrollment-tokens") public ResponseEntity issueEnrollmentToken( @Valid @RequestBody IssueEnrollmentTokenRequest request) { @@ -82,12 +98,20 @@ public ResponseEntity issueEnrollmentToken( ); } + @Operation(operationId = "revokeDevice", + summary = "Revoke a device's enrollment", + description = "Closes any live agent session. Idempotent: re-revoking an already revoked device is a no-op.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Device revoked"), + @ApiResponse(responseCode = "404", description = "No device with that id", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/{deviceId}/revoke") - public ResponseEntity revoke(@PathVariable UUID deviceId) { + public ResponseEntity revoke(@PathVariable UUID deviceId) { Optional found = deviceRepository.findById(deviceId); if (found.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new MessageResponse("Device not found")); + throw new ApiResponseException(HttpStatus.NOT_FOUND, + new MessageResponse("Device not found")); } Device d = found.get(); if (d.getRevokedAt() == null) { @@ -99,13 +123,19 @@ public ResponseEntity revoke(@PathVariable UUID deviceId) { return ResponseEntity.ok(DeviceSummary.of(d)); } + @Operation(operationId = "issueDeviceCommand", + summary = "Queue a command for a device", + description = "Returns as soon as the command is queued; delivery and execution are reported " + + "asynchronously over the agent websocket.") + @ApiResponse(responseCode = "202", description = "Command accepted and queued for delivery") @PostMapping("/{deviceId}/commands") - public ResponseEntity issueCommand(@PathVariable UUID deviceId, + public ResponseEntity issueCommand(@PathVariable UUID deviceId, @Valid @RequestBody IssueCommandRequest request) { CommandIssuedResponse response = commandDispatcher.issue(deviceId, getCurrentUserEmail(), request); return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); } + @Operation(operationId = "listDeviceCommands", summary = "Fetch the 50 most recent commands for a device") @GetMapping("/{deviceId}/commands") public ResponseEntity> recentCommands(@PathVariable UUID deviceId) { List commands = commandRepository diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java index 320ffdc..c107388 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/EventsController.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; @RestController @RequestMapping("/api/events") @@ -17,11 +18,13 @@ public class EventsController { private final EventsService eventsService; @GetMapping + @Operation(operationId = "getPublicEvents", summary = "List publicly visible events") public ResponseEntity> getAllEvents() { return ResponseEntity.ok(eventsService.getPublicVisibleEvents()); } @GetMapping("/{id}") + @Operation(operationId = "getPublicEventById", summary = "Fetch a single publicly visible event") public ResponseEntity getEventById(@PathVariable UUID id) { return eventsService.getEventById(id) .map(ResponseEntity::ok) diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java index ac6f5ed..29e2324 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/FileUploadController.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.dto.upload.response.PresignedUploadResponse; import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse; import com.ibrasoft.lensbridge.dto.upload.response.UploadDto; +import com.ibrasoft.lensbridge.dto.upload.response.UploadLimitsResponse; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.security.CurrentUser; @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*; import java.util.UUID; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api/upload") @@ -40,7 +42,7 @@ public ResponseEntity getUploadById(@PathVariable UUID uploadId) { @GetMapping("/event/{eventId}") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity> getUploadsByEvent(@PathVariable UUID eventId, Pageable pageable) { + public ResponseEntity> getUploadsByEvent(@PathVariable UUID eventId, @ParameterObject Pageable pageable) { return ResponseEntity.ok(uploadService.getUploadsByEventAsDto(eventId, pageable)); } @@ -79,7 +81,7 @@ public ResponseEntity completeDirectUpload( @GetMapping("/limits") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity getUploadLimits(@CurrentUser User user, Authentication authentication) { + public ResponseEntity getUploadLimits(@CurrentUser User user, Authentication authentication) { Role role = uploadLimitsService.getHighestRole(authentication); return ResponseEntity.ok(uploadLimitsService.getLimitsForRole(role, user.getId())); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java index da4fc68..02ee80f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/GalleryController.java @@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.*; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api") @@ -20,12 +22,13 @@ public class GalleryController { private final GalleryService galleryService; @GetMapping("/gallery") - public ResponseEntity> getAllUploads(Pageable pageable) { + @Operation(operationId = "getGalleryUploads", summary = "Page through approved gallery media") + public ResponseEntity> getAllUploads(@ParameterObject Pageable pageable) { return ResponseEntity.ok(galleryService.getAllApprovedGalleryItems(pageable)); } @GetMapping("/gallery/event/{eventId}") - public ResponseEntity> getGalleryByEvent(@PathVariable UUID eventId, Pageable pageable) { + public ResponseEntity> getGalleryByEvent(@PathVariable UUID eventId, @ParameterObject Pageable pageable) { return ResponseEntity.ok(galleryService.getGalleryItemsByEvent(eventId, pageable)); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index d3b46ea..07013df 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -17,6 +17,7 @@ import java.time.Instant; import java.util.List; import java.util.UUID; +import io.swagger.v3.oas.annotations.Operation; @RestController @RequestMapping("/api/musallah") @@ -31,6 +32,7 @@ public class MusallahBoardController { // ==================== Board Configuration ==================== @GetMapping("/config") + @Operation(operationId = "getPublicBoardConfig", summary = "Fetch the config a kiosk should render") public ResponseEntity getBoardConfig(@RequestParam UUID deviceId) { log.debug("Musallah board fetching config for device: {}", deviceId); return boardService.getBoardConfig(deviceId) @@ -49,6 +51,7 @@ public ResponseEntity getCurrentWeeklyContent() { } @GetMapping("/weekly-content/{year}/{weekNumber}") + @Operation(operationId = "getPublicWeeklyContent", summary = "Fetch weekly content for a kiosk") public ResponseEntity getWeeklyContent( @PathVariable int year, @PathVariable int weekNumber) { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java index d16c64c..0b2d65c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/UserController.java @@ -23,6 +23,7 @@ import com.ibrasoft.lensbridge.service.GalleryService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; +import org.springdoc.core.annotations.ParameterObject; @RestController @RequestMapping("/api/user") @@ -61,7 +62,7 @@ public ResponseEntity getUserStats(@CurrentUser User user) { @GetMapping("/uploads") @PreAuthorize("hasRole('" + Role.Authority.USER + "')") - public ResponseEntity> getUserUploads(Pageable pageable, @CurrentUser User user) { + public ResponseEntity> getUserUploads(@ParameterObject Pageable pageable, @CurrentUser User user) { return ResponseEntity.ok(galleryService.getUserGallery(user.getId(), pageable)); } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java new file mode 100644 index 0000000..5117c2c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java @@ -0,0 +1,24 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * Body for POST /api/auth/reset-password. + * + * Both fields were previously query parameters. A URL query string is recorded in + * server access logs, proxy logs and browser history, so the reset token and the + * new password were being written to places that outlive the request and are not + * treated as secret storage. A request body is not logged by default. + */ +@Data +public class ResetPasswordRequest { + + @NotBlank(message = "Reset token is required") + private String token; + + @NotBlank(message = "New password is required") + @Size(min = 6, max = 256, message = "Password must be between 6 and 256 characters long") + private String newPassword; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java new file mode 100644 index 0000000..6948d73 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java @@ -0,0 +1,18 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * Body for POST /api/auth/validate-reset-token. + * + * Deliberately separate from {@link VerifyEmailRequest}, which is also a lone + * token field: the two carry different credentials, and sharing one type would + * name the wrong thing in every generated client. + */ +@Data +public class ValidateResetTokenRequest { + + @NotBlank(message = "Reset token is required") + private String token; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java new file mode 100644 index 0000000..c33ba87 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java @@ -0,0 +1,11 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class VerifyEmailRequest { + + @NotBlank(message = "Verification token is required") + private String token; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java new file mode 100644 index 0000000..3a6200d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java @@ -0,0 +1,13 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.UUID; + +@Data +public class VerifyUserRequest { + + @NotNull(message = "userId is required") + private UUID userId; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java index f75bdde..44aefea 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java @@ -8,6 +8,9 @@ import java.time.Instant; +import org.hibernate.validator.constraints.URL; +import org.springframework.web.multipart.MultipartFile; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreatePosterRequest { @NotBlank(message = "Title is required") @@ -24,4 +27,10 @@ public class CreatePosterRequest { @NotNull(message = "Audience is required") private Audience audience; + + @NotNull(message = "Image file is required") + MultipartFile imageFile; + + @URL + private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java index 6ae4de1..6f12ab6 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -10,4 +10,10 @@ public class UpdateBoardConfigRequest { private Boolean darkModeAfterIsha; private Boolean enableScrollingMessage; private List scrollingMessages; + + /** + * Destination for the "Stay Connected" QR code on the board's closing slide + * Typically a social media page, but could be any URL + */ + private String socialUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java index de3ea33..c6cca3a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePosterRequest.java @@ -16,4 +16,11 @@ public class UpdatePosterRequest { private Instant startTime; private Instant endTime; private Audience audience; + + /** + * Sign-up link rendered as a QR code beside the poster. Send an empty string + * to clear it; null leaves the existing value alone, like every other field + * on this patch request. + */ + private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java index 744dba8..e73d2cc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.stream.Collectors; @RestControllerAdvice @Slf4j @@ -84,13 +85,14 @@ public ResponseEntity> handleMaxUploadSizeExceededException( } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { - Map errors = new HashMap<>(); - ex.getBindingResult().getAllErrors().forEach(error -> { - String fieldName = ((FieldError) error).getField(); - errors.put(fieldName, error.getDefaultMessage()); - }); - return ResponseEntity.badRequest().body(errors); + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + String details = ex.getBindingResult().getAllErrors().stream() + .map(error -> error instanceof FieldError fieldError + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return ResponseEntity.badRequest().body(new MessageResponse(details)); } @ExceptionHandler(UsernameNotFoundException.class) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java index da35769..ca47bab 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java @@ -1,5 +1,8 @@ package com.ibrasoft.lensbridge.model.board; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + import java.util.Arrays; import java.util.Optional; @@ -21,6 +24,17 @@ public static Optional from(String s) { .findFirst(); } + @JsonValue + public String toJson() { + return audienceName; + } + + /** Accepts either case, so pre-existing callers sending "BROTHERS" still bind. */ + @JsonCreator + public static Audience fromJson(String s) { + return from(s).orElseThrow(() -> new IllegalArgumentException("Unknown audience: " + s)); + } + @Override public String toString() { return audienceName; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java index 15adb58..1237925 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java @@ -31,4 +31,10 @@ public class Poster { @Enumerated(EnumType.STRING) @Column(nullable = false) private Audience audience; + + @Column(nullable = true) + /** + * Optional URL for the associated signup page. If provided, the frontend will show a QR Code linking to this URL. + */ + private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java index 3ee81b7..6a8f936 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -32,4 +32,16 @@ public class DeviceConfig { joinColumns = @JoinColumn(name = "device_id")) @Column(name = "message") private List scrollingMessages; + + /** + * Destination for the "Stay Connected" QR code on the closing slide. Optional: + * a board with no socialUrl renders that slide without a QR. + * + * Deliberately nullable. NOT NULL could not be satisfied here -- devices are + * enrolled with a default config that does not set this + * (DeviceEnrollmentService), and rows created before the column exists have + * no value to backfill from. + */ + @Column(name = "social_url") + private String socialUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java index db47cb4..95f3f86 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PosterFrameConfig.java @@ -26,4 +26,11 @@ public class PosterFrameConfig extends FrameConfig { * The title of the poster (optional, for accessibility/logging). */ private String title; + + /** + * Optional sign-up link. When present the board renders the poster beside a + * QR code encoding this URL rather than full-bleed, so someone walking past + * can register without typing anything. + */ + private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index ba91b0e..43b78cd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -91,7 +91,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/agent/enroll").permitAll() .requestMatchers("/api/agent/ws").permitAll() .requestMatchers("/api/dashboard/ws/**").permitAll() - .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() + // Disable these endpoints in production; they are only for local dev and CI. + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", + "/v3/api-docs", "/v3/api-docs.yaml", "/v3/api-docs/**").permitAll() .anyRequest().authenticated()); http.authenticationProvider(authenticationProvider()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 610e885..e9fa390 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -69,6 +69,11 @@ public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest re Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); + // Empty string clears the link, which drops the QR from the closing slide. + if (request.getSocialUrl() != null) { + String socialUrl = request.getSocialUrl().trim(); + existing.setSocialUrl(socialUrl.isEmpty() ? null : socialUrl); + } DeviceConfig saved = boardConfigRepository.save(existing); log.info("Updated board config for device: {}", deviceId); boardStream.configChanged(deviceId); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index 40f6646..36a21bf 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -82,15 +82,15 @@ public List getActivePosters() { /** * Create a new poster with an uploaded image. */ - public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) { + public Poster createPoster(CreatePosterRequest request) { validateDates(request.getStartTime(), request.getEndTime()); - validateImageFile(imageFile); + validateImageFile(request.getImageFile()); // Upload the image to R2 String objectKey; try { - String filename = generatePosterFilename(imageFile.getOriginalFilename()); - objectKey = r2StorageService.uploadImage(filename, imageFile); + String filename = generatePosterFilename(request.getImageFile().getOriginalFilename()); + objectKey = r2StorageService.uploadImage(filename, request.getImageFile()); log.info("Uploaded poster image to R2: {}", objectKey); } catch (IOException e) { log.error("Failed to upload poster image", e); @@ -106,6 +106,7 @@ public Poster createPoster(CreatePosterRequest request, MultipartFile imageFile) .startTime(request.getStartTime()) .endTime(request.getEndTime()) .audience(request.getAudience()) + .signupUrl(request.getSignupUrl()) .build(); poster = posterRepository.save(poster); @@ -130,6 +131,13 @@ public Poster updatePoster(UUID posterId, UpdatePosterRequest request) { Patch.apply(request.getStartTime(), poster::setStartTime); Patch.apply(request.getEndTime(), poster::setEndTime); Patch.apply(request.getAudience(), poster::setAudience); + // An empty string clears the link, so the QR panel can be removed from a + // poster without deleting and re-uploading it. Not Patch.apply: the lambda + // would capture `poster`, which is reassigned by save() below. + if (request.getSignupUrl() != null) { + String signupUrl = request.getSignupUrl().trim(); + poster.setSignupUrl(signupUrl.isEmpty() ? null : signupUrl); + } validateDates(poster.getStartTime(), poster.getEndTime()); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java index c718d54..07d046f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java @@ -21,6 +21,7 @@ public FrameDefinition transform(Poster poster, BoardContext ctx) { PosterFrameConfig config = PosterFrameConfig.builder() .posterUrl(poster.getImage()) .title(poster.getTitle()) + .signupUrl(poster.getSignupUrl()) .build(); return FrameDefinition.builder() diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java new file mode 100644 index 0000000..1f1fcb4 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java @@ -0,0 +1,128 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.exception.GlobalExceptionHandler; +import com.ibrasoft.lensbridge.security.LoginAttemptService; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.service.RefreshTokenService; +import com.ibrasoft.lensbridge.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Covers the request binding for POST /api/auth/reset-password. + * + * The token and new password moved out of the query string and into the request + * body, because a query string is written to access logs, proxy logs and browser + * history. These tests exercise the real bind-and-validate path so a regression + * back to @RequestParam fails here. + */ +class AuthControllerResetPasswordTest { + + private static final String URL = "/api/auth/reset-password"; + + private UserService userService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + + AuthController controller = new AuthController( + mock(AuthenticationManager.class), + userService, + mock(JwtUtils.class), + mock(LoginAttemptService.class), + mock(RefreshTokenService.class)); + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setValidator(validator) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void acceptsTokenAndPasswordInTheRequestBody() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"reset-token\",\"newPassword\":\"correct horse\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Password reset successfully.")); + + verify(userService).resetPassword("reset-token", "correct horse"); + } + + /** + * The shape this endpoint used to accept. It must now fail: if query parameters + * still worked, callers could keep putting a password in the URL. + */ + @Test + void rejectsCredentialsSuppliedAsQueryParameters() throws Exception { + mockMvc.perform(post(URL) + .param("token", "reset-token") + .param("newPassword", "correct horse")) + .andExpect(status().isBadRequest()); + + verify(userService, never()).resetPassword(anyString(), anyString()); + } + + @Test + void rejectsMissingToken() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"newPassword\":\"correct horse\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").exists()); + + verify(userService, never()).resetPassword(anyString(), anyString()); + } + + @Test + void rejectsBlankPassword() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"reset-token\",\"newPassword\":\" \"}")) + .andExpect(status().isBadRequest()); + + verify(userService, never()).resetPassword(anyString(), anyString()); + } + + @Test + void rejectsPasswordShorterThanTheMinimum() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"reset-token\",\"newPassword\":\"short\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").exists()); + + verify(userService, never()).resetPassword(anyString(), anyString()); + } + + /** + * Errors carry a MessageResponse like every other failure in this API, so the + * frontend's `error.message` shows the real reason. + */ + @Test + void validationFailuresReturnAMessageResponse() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"\",\"newPassword\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").isString()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java new file mode 100644 index 0000000..a6fb282 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java @@ -0,0 +1,115 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.exception.GlobalExceptionHandler; +import com.ibrasoft.lensbridge.security.LoginAttemptService; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.service.RefreshTokenService; +import com.ibrasoft.lensbridge.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Covers the request binding for POST /api/auth/validate-reset-token. + * + * This endpoint reads rather than writes, but it is a POST: the reset token is a + * credential, and only a request body keeps it out of access logs, proxy logs and + * browser history. These tests exercise the real bind-and-validate path so a + * regression back to a GET with @RequestParam fails here. + */ +class AuthControllerValidateResetTokenTest { + + private static final String URL = "/api/auth/validate-reset-token"; + + private UserService userService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + + AuthController controller = new AuthController( + mock(AuthenticationManager.class), + userService, + mock(JwtUtils.class), + mock(LoginAttemptService.class), + mock(RefreshTokenService.class)); + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setValidator(validator) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void acceptsTokenInTheRequestBody() throws Exception { + when(userService.validateResetToken("reset-token")).thenReturn(true); + + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"reset-token\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Token is valid.")); + + verify(userService).validateResetToken("reset-token"); + } + + /** + * The shape this endpoint used to accept. A GET cannot carry a body, so if this + * ever succeeds again the token is back in the query string. + */ + @Test + void noLongerAcceptsAGetWithTheTokenInTheQueryString() throws Exception { + mockMvc.perform(get(URL).param("token", "reset-token")) + .andExpect(status().isMethodNotAllowed()); + + verify(userService, never()).validateResetToken(anyString()); + } + + @Test + void rejectsQueryParametersOnThePost() throws Exception { + mockMvc.perform(post(URL).param("token", "reset-token")) + .andExpect(status().isBadRequest()); + + verify(userService, never()).validateResetToken(anyString()); + } + + @Test + void rejectsBlankToken() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\" \"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").exists()); + + verify(userService, never()).validateResetToken(anyString()); + } + + @Test + void rejectsExpiredOrUnknownToken() throws Exception { + when(userService.validateResetToken("stale")).thenReturn(false); + + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"stale\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Invalid or expired token.")); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/AudienceJsonTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/AudienceJsonTest.java new file mode 100644 index 0000000..af63cd8 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/AudienceJsonTest.java @@ -0,0 +1,53 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Audience must serialise as the lowercase form the OpenAPI spec enumerates. + * + * springdoc derives the documented enum values from toString(), so the spec has + * always said "brothers". Jackson, with no configuration, used name() and both + * emitted and demanded "BROTHERS". Every generated client therefore sent a value + * the server rejected, and every response disagreed with its own schema. + */ +class AudienceJsonTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void serialisesAsLowercase() throws Exception { + assertEquals("\"brothers\"", mapper.writeValueAsString(Audience.BROTHERS)); + assertEquals("\"sisters\"", mapper.writeValueAsString(Audience.SISTERS)); + assertEquals("\"both\"", mapper.writeValueAsString(Audience.BOTH)); + } + + @Test + void readsTheDocumentedLowercaseForm() throws Exception { + assertEquals(Audience.BROTHERS, mapper.readValue("\"brothers\"", Audience.class)); + assertEquals(Audience.SISTERS, mapper.readValue("\"sisters\"", Audience.class)); + assertEquals(Audience.BOTH, mapper.readValue("\"both\"", Audience.class)); + } + + /** Callers written against the previous uppercase behaviour must keep working. */ + @Test + void stillReadsTheLegacyUppercaseForm() throws Exception { + assertEquals(Audience.BROTHERS, mapper.readValue("\"BROTHERS\"", Audience.class)); + assertEquals(Audience.BOTH, mapper.readValue("\"Both\"", Audience.class)); + } + + @Test + void rejectsUnknownValues() { + assertThrows(Exception.class, () -> mapper.readValue("\"everyone\"", Audience.class)); + } + + @Test + void roundTrips() throws Exception { + for (Audience a : Audience.values()) { + assertEquals(a, mapper.readValue(mapper.writeValueAsString(a), Audience.class)); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java index fac6f59..da6f10f 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java @@ -150,12 +150,13 @@ void createPosterUploadsImageAndPersists() throws IOException { .startTime(Instant.now()) .endTime(Instant.now().plusSeconds(7200)) .audience(Audience.BROTHERS) + .imageFile(imageFile()) .build(); when(r2StorageService.uploadImage(anyString(), any(MultipartFile.class))) .thenReturn("poster-key.png"); when(posterRepository.save(any(Poster.class))).thenAnswer(inv -> inv.getArgument(0)); - Poster created = service.createPoster(request, imageFile()); + Poster created = service.createPoster(request); assertThat(created.getTitle()).isEqualTo("New"); assertThat(created.getImage()).isEqualTo("https://cdn.example.com/poster-key.png"); @@ -172,9 +173,10 @@ void createPosterRejectsInvalidDateRange() { .startTime(start) .endTime(start) // not after start .audience(Audience.BOTH) + .imageFile(imageFile()) .build(); - assertThatThrownBy(() -> service.createPoster(request, imageFile())) + assertThatThrownBy(() -> service.createPoster(request)) .isInstanceOf(ApiResponseException.class) .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) .isEqualTo(HttpStatus.BAD_REQUEST)); @@ -189,10 +191,10 @@ void createPosterRejectsMissingImage() { .startTime(Instant.now()) .endTime(Instant.now().plusSeconds(60)) .audience(Audience.BOTH) + .imageFile(new MockMultipartFile("file", new byte[0])) .build(); - MultipartFile empty = new MockMultipartFile("file", new byte[0]); - assertThatThrownBy(() -> service.createPoster(request, empty)) + assertThatThrownBy(() -> service.createPoster(request)) .isInstanceOf(ApiResponseException.class) .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) .isEqualTo(HttpStatus.BAD_REQUEST)); @@ -205,11 +207,11 @@ void createPosterRejectsNonImageContentType() { .duration(5) .startTime(Instant.now()) .endTime(Instant.now().plusSeconds(60)) + .imageFile(new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[]{1})) .audience(Audience.BOTH) .build(); - MultipartFile pdf = new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[]{1}); - assertThatThrownBy(() -> service.createPoster(request, pdf)) + assertThatThrownBy(() -> service.createPoster(request)) .isInstanceOf(ApiResponseException.class); } @@ -221,11 +223,12 @@ void createPosterWrapsUploadIoExceptionAsServerError() throws IOException { .startTime(Instant.now()) .endTime(Instant.now().plusSeconds(60)) .audience(Audience.BOTH) + .imageFile(imageFile()) .build(); when(r2StorageService.uploadImage(anyString(), any(MultipartFile.class))) .thenThrow(new IOException("boom")); - assertThatThrownBy(() -> service.createPoster(request, imageFile())) + assertThatThrownBy(() -> service.createPoster(request)) .isInstanceOf(ApiResponseException.class) .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)); diff --git a/tools/check_openapi.py b/tools/check_openapi.py new file mode 100644 index 0000000..2387ff0 --- /dev/null +++ b/tools/check_openapi.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Quality gate for the committed openapi.yaml. + +The generated clients in LensBridgeFrontend, MusallahBoard/frontend and the Go +agent are only as good as this spec. A response declared as a bare `type: object` +produces `unknown` in TypeScript and `map[string]interface{}` in Go, which is +indistinguishable from having no contract at all -- so the build should refuse it. + +Usage: + python tools/check_openapi.py openapi.yaml + python tools/check_openapi.py openapi.yaml --baseline old.yaml + +Exit codes: + 0 clean + 1 violations found + 2 bad invocation / unreadable spec +""" +import argparse +import sys + +try: + import yaml +except ImportError: + sys.exit("PyYAML is required: pip install pyyaml") + +HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options", "trace"} + +# Endpoints knowingly exempt from the typed-response rule, each with a reason. +# Keep this list short and justified -- it is the escape hatch that lets the gate +# stay strict everywhere else. +ALLOWED_UNTYPED = { + # ("POST", "/api/example"): "returns a binary stream, not JSON", +} + + +def operations(spec): + """Yield (method, path, operation) for every operation in the spec.""" + for path, item in (spec.get("paths") or {}).items(): + for method, op in (item or {}).items(): + if method.lower() in HTTP_METHODS and isinstance(op, dict): + yield method.upper(), path, op + + +def is_contentless(schema): + """True if the schema carries no usable type information.""" + if not schema: + return True + # `type: object` with no properties, no $ref, no additionalProperties + return set(schema) <= {"type"} and schema.get("type") == "object" + + +def find_untyped(spec): + out = [] + for method, path, op in operations(spec): + for code, resp in (op.get("responses") or {}).items(): + for media in (resp.get("content") or {}).values(): + if is_contentless(media.get("schema")): + if (method, path) in ALLOWED_UNTYPED: + continue + out.append((method, path, code, op.get("operationId"))) + return out + + +def find_bad_operation_ids(spec): + """ + operationIds become method names in generated clients, so they must be + unique, present, and meaningful. springdoc derives them from Java method + names and appends _2, _3 ... on collision, which produces churn in the + generated code every time an unrelated method is added. + """ + seen, dupes, missing, vague = {}, [], [], [] + # Single-word ids that say nothing once detached from their controller. + VAGUE = {"get", "list", "create", "update", "delete", "revoke", "post", "all"} + for method, path, op in operations(spec): + oid = op.get("operationId") + if not oid: + missing.append((method, path)) + continue + if oid.rstrip("0123456789").endswith("_"): + dupes.append((method, path, oid)) + if oid.lower() in VAGUE: + vague.append((method, path, oid)) + seen.setdefault(oid, []).append((method, path)) + collisions = [(o, v) for o, v in seen.items() if len(v) > 1] + return missing, dupes, vague, collisions + + +def compare_baseline(spec, baseline): + """Report operations present in baseline but missing now -- i.e. accidental + renames or deletions. Additions are fine and not reported.""" + now = {(m, p) for m, p, _ in operations(spec)} + was = {(m, p) for m, p, _ in operations(baseline)} + return sorted(was - now) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("spec") + ap.add_argument("--baseline", help="previous spec to diff against for removed operations") + ap.add_argument("--strict-ids", action="store_true", + help="also fail on vague/duplicate operationIds") + args = ap.parse_args() + + try: + with open(args.spec, encoding="utf-8") as fh: + spec = yaml.safe_load(fh) + except OSError as exc: + sys.exit(f"cannot read {args.spec}: {exc}") + + failed = False + + untyped = find_untyped(spec) + total = sum(1 for _ in operations(spec)) + if untyped: + failed = True + print(f"FAIL {len(untyped)} untyped response(s) across {total} operations:\n") + for method, path, code, oid in sorted(untyped, key=lambda r: (r[1], r[0])): + print(f" {method:6} {path} [{code}] {oid}") + print("\n These generate `unknown` in TypeScript and `interface{}` in Go.") + print(" Declare a concrete ResponseEntity on the controller method.") + else: + print(f"OK all {total} operations declare typed responses") + + missing, dupes, vague, collisions = find_bad_operation_ids(spec) + if missing: + failed = True + print(f"\nFAIL {len(missing)} operation(s) without an operationId:") + for method, path in missing: + print(f" {method:6} {path}") + for label, rows in (("auto-deduplicated", dupes), ("vague", vague)): + if rows: + if args.strict_ids: + failed = True + print(f"\n{'FAIL' if args.strict_ids else 'WARN'} {len(rows)} {label} operationId(s):") + for method, path, oid in rows: + print(f" {method:6} {path} -> {oid}") + if collisions: + failed = True + print(f"\nFAIL {len(collisions)} duplicated operationId(s):") + for oid, locs in collisions: + print(f" {oid}: {', '.join(f'{m} {p}' for m, p in locs)}") + + if args.baseline: + try: + with open(args.baseline, encoding="utf-8") as fh: + base = yaml.safe_load(fh) + except OSError as exc: + sys.exit(f"cannot read baseline {args.baseline}: {exc}") + removed = compare_baseline(spec, base) + if removed: + failed = True + print(f"\nFAIL {len(removed)} operation(s) in baseline are gone (renamed or deleted):") + for method, path in removed: + print(f" {method:6} {path}") + else: + print("OK no operations removed relative to baseline") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 18e49250307ce8c0c5cb6c17fca0958363bc9934 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 3 Aug 2026 01:36:31 -0400 Subject: [PATCH 34/52] Updates to board payload --- .../service/board/BoardContext.java | 4 +++ .../service/board/BoardPayloadAssembler.java | 2 +- .../EventListFrameTransformer.java | 7 ++-- .../service/board/BoardContextTest.java | 35 +++++++++++++++++++ .../EventListFrameTransformerTest.java | 4 +-- 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java index bfdb44d..a0f0df8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java @@ -57,6 +57,10 @@ public Instant currentWeekEnd() { .toInstant(); } + public Instant rollingWindowEnd() { + return now.plusDays(6).with(LocalTime.MAX).toInstant(); + } + public Instant currentDayStart() { return now.with(LocalTime.MIN).toInstant(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index 9595725..5b1912a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -80,7 +80,7 @@ private List dailyScheduleFrames(BoardContext ctx) { private List eventFrames(BoardContext ctx) { List boardEvents = boardService.getEventsForAudienceInRange( - ctx.getDevice().getAudience(), ctx.currentWeekStart(), ctx.currentWeekEnd()); + ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.rollingWindowEnd()); if (boardEvents.isEmpty()) return List.of(); return List.of(eventListTransformer.transform(boardEvents, ctx)); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java index 2bd318b..c7ef72a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java @@ -13,8 +13,9 @@ import java.util.stream.Collectors; /** - * Transforms a list of Events into a single EVENT_LIST FrameDefinition - * representing events for the current week. + * Transforms a list of Events into a single EVENT_LIST FrameDefinition representing the board's + * rolling agenda window (today and the next six days + * See {@link com.ibrasoft.lensbridge.service.board.BoardContext#rollingWindowEnd()}. */ @Component public class EventListFrameTransformer implements FrameTransformer> { @@ -31,7 +32,7 @@ public FrameDefinition transform(List boardEvents, BoardContext ctx) .collect(Collectors.toList()); EventListFrameConfig config = EventListFrameConfig.builder() - .heading("This Week") + .heading("Next 7 Days") .events(eventViews) .build(); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java index 17415d7..70568df 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java @@ -113,6 +113,41 @@ void weekBracketsTheMoment() { assertThat(ctx.currentWeekEnd()).isAfter(WEDNESDAY.toInstant()); } + @Test + void rollingWindowEndIsSixDaysOnAtEndOfDay() { + ZonedDateTime windowEnd = at(WEDNESDAY).rollingWindowEnd().atZone(ZoneOffset.UTC); + + assertThat(windowEnd.toLocalDate()).isEqualTo(WEDNESDAY.toLocalDate().plusDays(6)); + assertThat(windowEnd.getHour()).isEqualTo(23); + assertThat(windowEnd.getMinute()).isEqualTo(59); + } + + /** + * The reason the agenda window is not the ISO week: on a Saturday the ISO window ends + * tomorrow, so a board showing seven day columns would spend five of them on the past. + */ + @Test + void rollingWindowLooksAheadOnSaturdayWhereTheIsoWeekDoesNot() { + ZonedDateTime saturday = WEDNESDAY.plusDays(3); + BoardContext ctx = at(saturday); + + assertThat(ctx.currentWeekEnd().atZone(ZoneOffset.UTC).toLocalDate()) + .isEqualTo(saturday.toLocalDate().plusDays(1)); + assertThat(ctx.rollingWindowEnd().atZone(ZoneOffset.UTC).toLocalDate()) + .isEqualTo(saturday.toLocalDate().plusDays(6)); + assertThat(ctx.rollingWindowEnd()).isAfter(ctx.currentWeekEnd()); + } + + /** The window opens at today's midnight, never mid-day — this morning's events still show. */ + @Test + void rollingWindowStartsAtTodayMidnight() { + BoardContext ctx = at(WEDNESDAY); + + assertThat(ctx.currentDayStart().atZone(ZoneOffset.UTC).toLocalDate()) + .isEqualTo(WEDNESDAY.toLocalDate()); + assertThat(ctx.currentDayStart()).isBefore(ctx.rollingWindowEnd()); + } + @Test void currentDayStartAndEndBracketTheMoment() { BoardContext ctx = at(WEDNESDAY); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java index 304b7d6..09d4962 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java @@ -57,7 +57,7 @@ void transformMapsEventFieldsIncludingAllDay() { FrameDefinition def = transformer.transform(List.of(event("Conference", true)), null); EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); - assertThat(config.getHeading()).isEqualTo("This Week"); + assertThat(config.getHeading()).isEqualTo("Next 7 Days"); assertThat(config.getEvents()).hasSize(1); EventView view = config.getEvents().get(0); assertThat(view.getName()).isEqualTo("Conference"); @@ -84,7 +84,7 @@ void transformWithEmptyListProducesEmptyEventsButValidFrame() { FrameDefinition def = transformer.transform(Collections.emptyList(), null); EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); - assertThat(config.getHeading()).isEqualTo("This Week"); + assertThat(config.getHeading()).isEqualTo("Next 7 Days"); assertThat(config.getEvents()).isEmpty(); assertThat(def.getFrameType()).isEqualTo(FrameType.EVENT_LIST); } From 98536c35b0abed5cd2e56489dbc6a0e62536253e Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 5 Aug 2026 18:52:35 -0400 Subject: [PATCH 35/52] smol updates --- .../lensbridge/dto/board/request/CreatePosterRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java index 44aefea..acd4f94 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePosterRequest.java @@ -31,6 +31,6 @@ public class CreatePosterRequest { @NotNull(message = "Image file is required") MultipartFile imageFile; - @URL + @URL(message = "Signup URL must be a valid URL") private String signupUrl; } From 281aab1d3d5a34029c31b289e410aad54d49639b Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Thu, 6 Aug 2026 23:49:59 -0400 Subject: [PATCH 36/52] smol fixes --- .../ibrasoft/lensbridge/security/services/EmailService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java b/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java index 5824328..aa4585b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/EmailService.java @@ -9,8 +9,8 @@ import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; +import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.Map; import org.slf4j.Logger; @@ -122,7 +122,9 @@ private void sendHtmlEmail(String to, String subject, String htmlContent) { private String loadEmailTemplate(String templateName) { try { ClassPathResource resource = new ClassPathResource("templates/" + templateName); - return new String(Files.readAllBytes(resource.getFile().toPath()), StandardCharsets.UTF_8); + try (InputStream in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } } catch (Exception e) { logger.error("Failed to load email template: {} - Error: {}", templateName, e.getMessage(), e); throw new RuntimeException("Failed to load email template", e); From 3cb088980f8e8172b888f035f1525eaaba5cea25 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Fri, 7 Aug 2026 21:23:57 -0400 Subject: [PATCH 37/52] Revamp permissions system --- openapi.yaml | 271 ++++++++++++++- .../stomp/StompJwtChannelInterceptor.java | 107 +++++- .../controller/AdminController.java | 116 ++++++- .../lensbridge/controller/AuthController.java | 21 +- .../controller/BoardAdminController.java | 84 ++++- .../controller/DeviceAdminController.java | 63 +++- .../lensbridge/dto/audit/AuditEventDto.java | 1 + .../dto/auth/response/JwtResponse.java | 1 + .../dto/auth/response/PermissionResponse.java | 24 ++ .../auth/response/RoleDefinitionResponse.java | 26 ++ .../response/TokenValidationResponse.java | 1 + .../dto/auth/response/UserInfoResponse.java | 10 +- .../board/request/UpdateTickerRequest.java | 25 ++ .../lensbridge/model/audit/AuditAction.java | 19 +- .../model/audit/AuditEntityType.java | 1 + .../lensbridge/model/audit/AuditEvent.java | 5 + .../lensbridge/model/auth/Permission.java | 116 +++++++ .../ibrasoft/lensbridge/model/auth/Role.java | 44 ++- .../model/auth/RolePermissions.java | 99 ++++++ .../ibrasoft/lensbridge/model/auth/User.java | 17 + .../model/board/commands/CommandKind.java | 65 ++++ .../model/board/commands/CommandRisk.java | 34 ++ .../repository/auth/UserRepository.java | 22 ++ .../security/CommandAuthorizer.java | 53 +++ .../security/services/AuthorityResolver.java | 58 ++++ .../services/UserDetailsServiceImpl.java | 16 +- .../lensbridge/service/AdminAuditService.java | 23 +- .../lensbridge/service/BoardService.java | 16 +- .../lensbridge/service/UserService.java | 226 +++++++++++- .../service/agent/CommandDispatcher.java | 22 +- .../stomp/StompJwtChannelInterceptorTest.java | 191 +++++++++++ .../AdminControllerIntegrationTest.java | 157 +++++++-- .../AuthControllerResetPasswordTest.java | 3 +- .../AuthControllerValidateResetTokenTest.java | 3 +- .../BoardAdminControllerPermissionTest.java | 322 +++++++++--------- .../controller/DeviceAdminControllerTest.java | 48 ++- .../controller/TestAuthorities.java | 56 +++ .../lensbridge/model/auth/PermissionTest.java | 54 +++ .../model/auth/RolePermissionsTest.java | 123 +++++++ .../model/board/commands/CommandKindTest.java | 97 ++++++ .../security/CommandAuthorizerTest.java | 113 ++++++ .../services/AuthorityResolverTest.java | 106 ++++++ .../lensbridge/service/UserServiceTest.java | 222 +++++++++++- 43 files changed, 2789 insertions(+), 292 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/TestAuthorities.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/auth/PermissionTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/auth/RolePermissionsTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/commands/CommandKindTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/security/CommandAuthorizerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/security/services/AuthorityResolverTest.java diff --git a/openapi.yaml b/openapi.yaml index 85a7b0b..42ea025 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -76,6 +76,14 @@ paths: - CREATE_POSTER - UPDATE_POSTER - DELETE_POSTER + - SAVE_WEEKLY_CONTENT + - DELETE_WEEKLY_CONTENT + - UPDATE_BOARD_CONFIG + - UPDATE_BOARD_TICKER + - REFRESH_BOARDS + - ISSUE_ENROLLMENT_TOKEN + - REVOKE_DEVICE + - ISSUE_DEVICE_COMMAND - PROMOTE_USER - DEMOTE_USER - DISABLE_USER @@ -89,6 +97,8 @@ paths: - TRIGGER_PASSWORD_RESET_EMAIL - ADD_USER_ROLE - REMOVE_USER_ROLE + - GRANT_PERMISSION + - REVOKE_PERMISSION - ADD_USER - REMOVE_USER - UPDATE_USER @@ -158,6 +168,14 @@ paths: - CREATE_POSTER - UPDATE_POSTER - DELETE_POSTER + - SAVE_WEEKLY_CONTENT + - DELETE_WEEKLY_CONTENT + - UPDATE_BOARD_CONFIG + - UPDATE_BOARD_TICKER + - REFRESH_BOARDS + - ISSUE_ENROLLMENT_TOKEN + - REVOKE_DEVICE + - ISSUE_DEVICE_COMMAND - PROMOTE_USER - DEMOTE_USER - DISABLE_USER @@ -171,6 +189,8 @@ paths: - TRIGGER_PASSWORD_RESET_EMAIL - ADD_USER_ROLE - REMOVE_USER_ROLE + - GRANT_PERMISSION + - REVOKE_PERMISSION - ADD_USER - REMOVE_USER - UPDATE_USER @@ -417,6 +437,38 @@ paths: description: Request failed; body carries a human-readable message tags: - board-admin-controller + /api/admin/board/configs/{deviceId}/ticker: + patch: + operationId: updateBoardTicker + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTickerRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceConfig" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Update a board's scrolling messages + tags: + - board-admin-controller /api/admin/board/devices: get: operationId: listDevices @@ -524,8 +576,9 @@ paths: tags: - device-admin-controller post: - description: Returns as soon as the command is queued; delivery and execution - are reported asynchronously over the agent websocket. + description: "Returns as soon as the command is queued; delivery and execution\ + \ are reported asynchronously over the agent websocket. The permission required\ + \ depends on the command kind: board:command:benign, :disruptive, or :inspect." operationId: issueDeviceCommand parameters: - in: path @@ -547,6 +600,12 @@ paths: schema: $ref: "#/components/schemas/CommandIssuedResponse" description: Command accepted and queued for delivery + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Caller lacks the permission for this command kind default: content: application/json: @@ -1181,6 +1240,27 @@ paths: description: Request failed; body carries a human-readable message tags: - admin-controller + /api/admin/permissions: + get: + operationId: getAvailablePermissions + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PermissionResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: "List every permission, for building a direct-grant picker" + tags: + - admin-controller /api/admin/roles: get: operationId: getAvailableRoles @@ -1191,11 +1271,7 @@ paths: schema: type: array items: - type: string - enum: - - USER - - ADMIN - - ROOT + $ref: "#/components/schemas/RoleDefinitionResponse" description: OK default: content: @@ -1203,7 +1279,7 @@ paths: schema: $ref: "#/components/schemas/MessageResponse" description: Request failed; body carries a human-readable message - summary: List the roles that can be assigned to a user + summary: List assignable roles with the permissions each one confers tags: - admin-controller /api/admin/upload/{uploadId}: @@ -1548,6 +1624,9 @@ paths: enum: - USER - ADMIN + - BOARD_VIEWER + - BOARD_EDITOR + - BOARD_ADMIN - ROOT required: true responses: @@ -1565,6 +1644,62 @@ paths: description: Request failed; body carries a human-readable message tags: - admin-controller + /api/admin/user/{userId}/grant-permission: + post: + operationId: grantUserPermission + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - MEDIA_UPLOAD_SELF + - MEDIA_UPLOAD_MODERATE + - MEDIA_UPLOAD_READ + - MEDIA_EVENT_WRITE + - BOARD_CONTENT_READ + - BOARD_POSTER_WRITE + - BOARD_EVENT_WRITE + - BOARD_WEEKLY_WRITE + - BOARD_TICKER_WRITE + - BOARD_CONFIG_READ + - BOARD_CONFIG_WRITE + - BOARD_REFRESH + - BOARD_DEVICE_READ + - BOARD_DEVICE_ENROLL + - BOARD_DEVICE_REVOKE + - BOARD_TELEMETRY_SUBSCRIBE + - BOARD_COMMAND_BENIGN + - BOARD_COMMAND_DISRUPTIVE + - BOARD_COMMAND_INSPECT + - IAM_USER_READ + - IAM_USER_WRITE + - IAM_ROLE_GRANT + - AUDIT_READ + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Grant a single permission directly to a user + tags: + - admin-controller /api/admin/user/{userId}/remove-role: post: operationId: removeRoleFromUser @@ -1583,6 +1718,9 @@ paths: enum: - USER - ADMIN + - BOARD_VIEWER + - BOARD_EDITOR + - BOARD_ADMIN - ROOT required: true responses: @@ -1600,6 +1738,62 @@ paths: description: Request failed; body carries a human-readable message tags: - admin-controller + /api/admin/user/{userId}/revoke-permission: + post: + operationId: revokeUserPermission + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: string + enum: + - MEDIA_UPLOAD_SELF + - MEDIA_UPLOAD_MODERATE + - MEDIA_UPLOAD_READ + - MEDIA_EVENT_WRITE + - BOARD_CONTENT_READ + - BOARD_POSTER_WRITE + - BOARD_EVENT_WRITE + - BOARD_WEEKLY_WRITE + - BOARD_TICKER_WRITE + - BOARD_CONFIG_READ + - BOARD_CONFIG_WRITE + - BOARD_REFRESH + - BOARD_DEVICE_READ + - BOARD_DEVICE_ENROLL + - BOARD_DEVICE_REVOKE + - BOARD_TELEMETRY_SUBSCRIBE + - BOARD_COMMAND_BENIGN + - BOARD_COMMAND_DISRUPTIVE + - BOARD_COMMAND_INSPECT + - IAM_USER_READ + - IAM_USER_WRITE + - IAM_ROLE_GRANT + - AUDIT_READ + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + summary: Revoke a directly-granted permission from a user + tags: + - admin-controller /api/admin/users: get: operationId: getAllUsers @@ -2813,6 +3007,14 @@ components: - CREATE_POSTER - UPDATE_POSTER - DELETE_POSTER + - SAVE_WEEKLY_CONTENT + - DELETE_WEEKLY_CONTENT + - UPDATE_BOARD_CONFIG + - UPDATE_BOARD_TICKER + - REFRESH_BOARDS + - ISSUE_ENROLLMENT_TOKEN + - REVOKE_DEVICE + - ISSUE_DEVICE_COMMAND - PROMOTE_USER - DEMOTE_USER - DISABLE_USER @@ -2826,6 +3028,8 @@ components: - TRIGGER_PASSWORD_RESET_EMAIL - ADD_USER_ROLE - REMOVE_USER_ROLE + - GRANT_PERMISSION + - REVOKE_PERMISSION - ADD_USER - REMOVE_USER - UPDATE_USER @@ -2836,6 +3040,8 @@ components: format: uuid adminName: type: string + details: + type: string id: type: string format: uuid @@ -2850,6 +3056,7 @@ components: - USER - UPLOAD - MUSALLAH_BOARD + - DEVICE - EVENT timestamp: type: string @@ -3316,6 +3523,10 @@ components: format: uuid lastName: type: string + permissions: + type: array + items: + type: string refreshToken: type: string roles: @@ -3578,6 +3789,15 @@ components: type: array items: type: string + PermissionResponse: + type: object + properties: + authority: + type: string + domain: + type: string + name: + type: string Poster: type: object properties: @@ -3664,6 +3884,17 @@ components: required: - newPassword - token + RoleDefinitionResponse: + type: object + properties: + description: + type: string + name: + type: string + permissions: + type: array + items: + type: string SignupRequest: type: object properties: @@ -3734,6 +3965,10 @@ components: format: uuid lastName: type: string + permissions: + type: array + items: + type: string roles: type: array items: @@ -3819,6 +4054,15 @@ components: type: string maxLength: 10 minLength: 0 + UpdateTickerRequest: + type: object + properties: + enableScrollingMessage: + type: boolean + scrollingMessages: + type: array + items: + type: string UploadCompletionResponse: type: object properties: @@ -3905,6 +4149,14 @@ components: UserInfoResponse: type: object properties: + directPermissions: + type: array + items: + type: string + effectivePermissions: + type: array + items: + type: string email: type: string firstName: @@ -3921,6 +4173,9 @@ components: enum: - USER - ADMIN + - BOARD_VIEWER + - BOARD_EDITOR + - BOARD_ADMIN - ROOT uniqueItems: true studentNumber: diff --git a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java index 6cb1427..d5ee5ae 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptor.java @@ -1,41 +1,86 @@ package com.ibrasoft.lensbridge.config.stomp; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.security.jwt.JwtUtils; import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + +import org.springframework.lang.NonNull; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import java.security.Principal; + /** - * Validates the JWT carried in the STOMP {@code CONNECT} frame's {@code Authorization} - * native header. Subsequent SUBSCRIBE / SEND frames inherit the resolved principal via - * {@link StompHeaderAccessor#setUser(java.security.Principal)}. + * Authenticates STOMP {@code CONNECT} and authorizes STOMP {@code SUBSCRIBE}. *

+ * CONNECT: validates the JWT carried in the {@code Authorization} native header. * Browsers cannot set arbitrary headers on the WebSocket HTTP upgrade, so we authenticate - * at the STOMP layer rather than the HTTP handshake. + * at the STOMP layer rather than the HTTP handshake. Subsequent frames inherit the resolved + * principal via {@link StompHeaderAccessor#setUser(Principal)}. + *

+ * SUBSCRIBE: a valid JWT is not enough to read the device topics. Command results + * are published to {@code /topic/devices/{id}/commands} including the agent's raw output, + * which for {@code chrome.screenshot} is an image of a display and for {@code logs.tail} is + * agent internals. Reading those requires {@link Permission#BOARD_TELEMETRY_SUBSCRIBE}. + * Without this check any verified user — including a student who signed up only to upload a + * photo — could subscribe and receive them. + *

+ * Pattern destinations are refused outright, before any permission is considered. The + * broker treats a subscription destination as an Ant pattern, so {@code /topic/**} would + * otherwise pass a prefix test while still receiving every device message. */ @Component @RequiredArgsConstructor @Slf4j public class StompJwtChannelInterceptor implements ChannelInterceptor { + /** + * The only destination prefix the backend publishes to today (see DeviceEventPublisher). + * A new topic family must make its own authorization decision here. + */ + private static final String DEVICE_TOPIC_PREFIX = "/topic/devices"; + + /** + * AntPathMatcher metacharacters. {@code DefaultSubscriptionRegistry} matches a + * subscription destination against published destinations as a pattern, so any + * of these turns a SUBSCRIBE into a wildcard that a prefix test cannot reason about: + * {@code /topic/**} does not start with {@code /topic/devices} yet receives everything + * published there. Nothing in this application subscribes by pattern, so they are + * refused outright rather than pattern-matched against a policy. + */ + private static final char[] PATTERN_CHARS = {'*', '?', '{'}; + private final JwtUtils jwtUtils; private final UserDetailsServiceImpl userDetailsService; + @SuppressWarnings("null") // accessor is null-checked prior to switch statement @Override - public Message preSend(Message message, MessageChannel channel) { + public Message preSend(@NonNull Message message, @NonNull MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); - if (accessor == null || !StompCommand.CONNECT.equals(accessor.getCommand())) { + if (accessor == null || accessor.getCommand() == null) { return message; } + switch (accessor.getCommand()) { + case CONNECT -> authenticate(accessor); + case SUBSCRIBE -> authorizeSubscription(accessor); + default -> { /* other frames inherit the principal resolved at CONNECT */ } + } + return message; + } + + private void authenticate(StompHeaderAccessor accessor) { String header = accessor.getFirstNativeHeader("Authorization"); String token = stripBearer(header); if (token == null) { @@ -53,7 +98,55 @@ public Message preSend(Message message, MessageChannel channel) { new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); accessor.setUser(auth); log.debug("STOMP authenticated as {}", username); - return message; + } + + private void authorizeSubscription(StompHeaderAccessor accessor) { + String destination = accessor.getDestination(); + if (destination == null) { + return; + } + + // Checked before the prefix test, and regardless of what the subscriber holds: a + // pattern is not a destination this gate can authorize, so there is no version of it + // that is allowed through. + if (isPattern(destination)) { + log.warn("STOMP SUBSCRIBE rejected: pattern destination {}", destination); + throw new AccessDeniedException( + "Pattern subscriptions are not permitted; subscribe to an exact destination"); + } + + if (!destination.startsWith(DEVICE_TOPIC_PREFIX)) { + return; + } + + Principal principal = accessor.getUser(); + if (!(principal instanceof Authentication auth) || !auth.isAuthenticated()) { + log.warn("STOMP SUBSCRIBE to {} rejected: no authenticated principal", destination); + throw new AccessDeniedException("Not authenticated"); + } + if (!hasAuthority(auth, Permission.BOARD_TELEMETRY_SUBSCRIBE)) { + log.warn("STOMP SUBSCRIBE to {} rejected for {}: missing {}", + destination, auth.getName(), Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + throw new AccessDeniedException("Missing permission " + + Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + } + log.debug("STOMP SUBSCRIBE to {} authorized for {}", destination, auth.getName()); + } + + private static boolean isPattern(String destination) { + for (char c : PATTERN_CHARS) { + if (destination.indexOf(c) >= 0) return true; + } + return false; + } + + private static boolean hasAuthority(Authentication auth, Permission permission) { + for (GrantedAuthority granted : auth.getAuthorities()) { + if (permission.getAuthority().equals(granted.getAuthority())) { + return true; + } + } + return false; } private static String stripBearer(String header) { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 37e162b..9daf446 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -5,9 +5,12 @@ import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.VerifyUserRequest; +import com.ibrasoft.lensbridge.dto.auth.response.PermissionResponse; +import com.ibrasoft.lensbridge.dto.auth.response.RoleDefinitionResponse; import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.upload.MediaEvent; @@ -30,16 +33,33 @@ import org.springframework.web.bind.annotation.*; import java.time.Instant; +import java.util.Arrays; import java.util.List; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; import org.springdoc.core.annotations.ParameterObject; +/** + * Media moderation, identity administration, and audit reporting. + *

+ * The class-level gate covers the legacy media endpoints only — the identity and audit + * methods below override it with their own permission. Media authorization is unchanged: + * the same people reach the same endpoints they always did. + *

+ * The permission is accepted alongside the role because the role check alone is + * name-based, and a name-based check cannot see that {@code ROOT}'s bundle already contains + * {@code media:upload:moderate}. Without this, a ROOT-only account is refused media + * moderation while holding, on paper, every permission in the system — and the only fix + * would be granting it {@code ROLE_ADMIN} as well, which is the hidden role coupling this + * redesign exists to remove. + */ @RestController @RequestMapping("/api/admin") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.Authority.ADMIN + "')") +// Hacky fix until I remove media-related permissions from the legacy ROLE_ADMIN +@PreAuthorize("hasRole('" + Role.Authority.ADMIN + "') " + + "or hasAuthority('" + Permission.Authority.MEDIA_UPLOAD_MODERATE + "')") @Slf4j public class AdminController { @@ -55,8 +75,18 @@ private ResponseEntity executeUploadAction(UUID uploadId, HttpS } private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage) { + return executeUserAction(userId, request, serviceAction, auditAction, successMessage, null); + } + + /** + * @param details what was granted or revoked. Without it the audit log cannot tell a + * grant of {@code board:content:read} from a grant of + * {@code iam:role:grant} — the two highest- and lowest-stakes actions + * available here produce identical rows. + */ + private ResponseEntity executeUserAction(UUID userId, HttpServletRequest request, Consumer serviceAction, AuditAction auditAction, String successMessage, String details) { serviceAction.accept(userId); - this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "User", userId, request.getRemoteAddr()); + this.auditService.logAuditEvent(getCurrentUserEmail(), auditAction, "User", userId, request.getRemoteAddr(), details); return ResponseEntity.ok(new MessageResponse(successMessage)); } @@ -139,31 +169,63 @@ public ResponseEntity> getAllEvents() { } @GetMapping("/users") - @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')") public ResponseEntity> getAllUsers(@ParameterObject Pageable pageable) { return ResponseEntity.ok(userService.getAllUsers(pageable)); } @PostMapping("/user/{userId}/add-role") - @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") public ResponseEntity addRoleToUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, - (id) -> userService.addRole(id, role), + (id) -> userService.addRole(getCurrentUserEmail(), id, role), AuditAction.ADD_USER_ROLE, - "Role added successfully to user: " + userId); + "Role added successfully to user: " + userId, + "role=" + role.name()); } @PostMapping("/user/{userId}/remove-role") - @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") public ResponseEntity removeRoleFromUser(@PathVariable UUID userId, @RequestBody Role role, HttpServletRequest request) { return executeUserAction(userId, request, - (id) -> userService.removeRole(id, role), + (id) -> userService.removeRole(getCurrentUserEmail(), id, role), AuditAction.REMOVE_USER_ROLE, - "Role removed successfully from user: " + userId); + "Role removed successfully from user: " + userId, + "role=" + role.name()); + } + + /** + * Grants one permission on top of a user's roles. This is the pressure valve that keeps + * the role list short: a one-off need is a direct grant, not a new role. + */ + @PostMapping("/user/{userId}/grant-permission") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") + @Operation(operationId = "grantUserPermission", summary = "Grant a single permission directly to a user") + public ResponseEntity grantPermission(@PathVariable UUID userId, + @RequestBody Permission permission, + HttpServletRequest request) { + return executeUserAction(userId, request, + (id) -> userService.grantPermission(getCurrentUserEmail(), id, permission), + AuditAction.GRANT_PERMISSION, + "Permission " + permission.getAuthority() + " granted to user: " + userId, + "permission=" + permission.getAuthority()); + } + + @PostMapping("/user/{userId}/revoke-permission") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_ROLE_GRANT + "')") + @Operation(operationId = "revokeUserPermission", summary = "Revoke a directly-granted permission from a user") + public ResponseEntity revokePermission(@PathVariable UUID userId, + @RequestBody Permission permission, + HttpServletRequest request) { + return executeUserAction(userId, request, + (id) -> userService.revokePermission(getCurrentUserEmail(), id, permission), + AuditAction.REVOKE_PERMISSION, + "Permission " + permission.getAuthority() + " revoked from user: " + userId, + "permission=" + permission.getAuthority()); } @PostMapping("/user/verify") - @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_WRITE + "')") public ResponseEntity verifyUser(@Valid @RequestBody VerifyUserRequest payload, HttpServletRequest request) { return executeUserAction(payload.getUserId(), request, userService::verifyDirectly, AuditAction.VERIFY_USER, @@ -171,7 +233,7 @@ public ResponseEntity verifyUser(@Valid @RequestBody VerifyUser } @PostMapping("/user/create") - @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_WRITE + "')") public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, HttpServletRequest request) { return executeCreationAction(signUpRequest, request, (signupRequest) -> { @@ -186,41 +248,65 @@ public ResponseEntity createUser(@RequestBody SignupRequest sig "User created successfully: " + signUpRequest.getEmail()); } - @Operation(operationId = "getAvailableRoles", summary = "List the roles that can be assigned to a user") + @Operation(operationId = "getAvailableRoles", + summary = "List assignable roles with the permissions each one confers") @GetMapping("/roles") - @PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") - public ResponseEntity> getAvailableRoles() { - return ResponseEntity.ok(List.of(Role.values())); + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')") + public ResponseEntity> getAvailableRoles() { + return ResponseEntity.ok(Arrays.stream(Role.values()) + .map(RoleDefinitionResponse::of) + .toList()); + } + + @Operation(operationId = "getAvailablePermissions", + summary = "List every permission, for building a direct-grant picker") + @GetMapping("/permissions") + @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')") + public ResponseEntity> getAvailablePermissions() { + return ResponseEntity.ok(Arrays.stream(Permission.values()) + .map(PermissionResponse::of) + .toList()); } // Audit Reporting Endpoints + // + // Annotated with audit:read rather than inheriting the class-level ADMIN role, because + // the audit log now covers board and device actions too — a BOARD_ADMIN needs to read + // it without being handed media moderation. + @GetMapping("/audit") + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") public ResponseEntity> getAuditEvents(@ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getAllAuditEvents(pageable)); } @GetMapping("/audit/failed") + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") public ResponseEntity> getFailedOperations(@ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getFailedOperations(pageable)); } @GetMapping("/audit/upload/{uploadId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") public ResponseEntity> getUploadAuditHistory(@PathVariable UUID uploadId) { return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId)); } @GetMapping("/audit/action/{action}") + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") public ResponseEntity> getAuditEventsByAction(@PathVariable AuditAction action, @ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getAuditEventsByAction(action, pageable)); } @GetMapping("/audit/daterange") + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") public ResponseEntity> getAuditEventsByDateRange(@RequestParam Instant start, @RequestParam Instant end, @ParameterObject Pageable pageable) { return ResponseEntity.ok(auditService.getAuditEventsByDateRange(start, end, pageable)); } @Operation(operationId = "getAvailableAuditActions", summary = "List the audit action types available for filtering") @GetMapping("/audit/actions") + @PreAuthorize("hasAuthority('" + Permission.Authority.AUDIT_READ + "')") public ResponseEntity> getAvailableActions() { return ResponseEntity.ok(List.of(AuditAction.values())); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java index 9492a7a..92bbf78 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AuthController.java @@ -38,9 +38,11 @@ import com.ibrasoft.lensbridge.dto.auth.response.TokenValidationResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.exception.RefreshTokenException; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.RefreshToken; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.security.services.AuthorityResolver; import com.ibrasoft.lensbridge.security.CurrentUser; import com.ibrasoft.lensbridge.security.LoginAttemptService; import com.ibrasoft.lensbridge.security.jwt.JwtUtils; @@ -58,6 +60,7 @@ public class AuthController { private final JwtUtils jwtUtils; private final LoginAttemptService loginAttemptService; private final RefreshTokenService refreshTokenService; + private final AuthorityResolver authorityResolver; @Operation(operationId = "signIn", summary = "Authenticate with email and password") @ApiResponses({ @@ -92,8 +95,17 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginReq RefreshToken refreshToken = refreshTokenService.createRefreshToken( user.getId(), request.getHeader("User-Agent"), getClientIpAddress(request)); - List roles = authentication.getAuthorities().stream() + // TODO: This is probably slow and should be fitted in the data model somewhere + // Replace with a more efficient way to get roles and permissions if performance becomes an issue + List authorities = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) + .toList(); + List roles = authorities.stream() + .filter(a -> a.startsWith("ROLE_")) + .collect(Collectors.toList()); + List permissions = authorities.stream() + .filter(a -> !a.startsWith("ROLE_")) + .sorted() .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, @@ -102,7 +114,8 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginReq user.getLastName(), user.getId(), user.getEmail(), - roles)); + roles, + permissions)); } catch (Exception e) { loginAttemptService.recordFailedAttempt(clientKey); throw e; @@ -287,7 +300,9 @@ public ResponseEntity validateToken(Authentication auth user.getLastName(), user.getEmail(), user.isVerified(), - user.getRoles().stream().map(Role::getAuthority).collect(Collectors.toList()) + user.getRoles().stream().map(Role::getAuthority).collect(Collectors.toList()), + authorityResolver.resolvePermissions(user).stream() + .map(Permission::getAuthority).sorted().collect(Collectors.toList()) )); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 438698c..7cce6f8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -5,6 +5,7 @@ import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateTickerRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.handler.BoardStreamHandler; @@ -14,7 +15,7 @@ import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; @@ -40,12 +41,15 @@ /** * Admin controller for managing board content (posters, calendar events, board config, weekly content). * Returns raw entity data for admin console viewing/editing. - * All endpoints require ROOT role access. + *

+ * Authorization is per-endpoint by permission, not by role. There is deliberately no + * class-level {@code @PreAuthorize}: reading a poster and moving a board's coordinates are + * not the same privilege, and a blanket annotation is what made them the same before. + * See {@code docs/PERMISSIONS.md}. */ @RestController @RequestMapping("/api/admin/board") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") @Slf4j public class BoardAdminController { @@ -57,12 +61,14 @@ public class BoardAdminController { // ==================== Board Config Endpoints ==================== @GetMapping("/configs") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_READ + "')") public ResponseEntity> getAllBoardConfigs() { log.debug("Admin fetching all board configs"); return ResponseEntity.ok(boardService.getAllBoardConfigs()); } @GetMapping("/configs/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_READ + "')") @Operation(operationId = "getAdminBoardConfig", summary = "Fetch a device config as an administrator") public ResponseEntity getBoardConfig(@PathVariable UUID deviceId) { log.debug("Admin fetching board config for device: {}", deviceId); @@ -70,6 +76,7 @@ public ResponseEntity getBoardConfig(@PathVariable UUID deviceId) } @PutMapping("/configs/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_WRITE + "')") public ResponseEntity saveBoardConfig( @PathVariable UUID deviceId, @Valid @RequestBody DeviceConfig deviceConfig, @@ -77,10 +84,15 @@ public ResponseEntity saveBoardConfig( log.info("Admin saving board config for device: {}", deviceId); DeviceConfig saved = boardService.saveBoardConfig(deviceId, deviceConfig); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_CONFIG, + "BoardConfig", deviceId, request.getRemoteAddr()); + return ResponseEntity.ok(saved); } @PatchMapping("/configs/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_WRITE + "')") public ResponseEntity updateBoardConfig( @PathVariable UUID deviceId, @Valid @RequestBody UpdateBoardConfigRequest updateRequest, @@ -88,24 +100,53 @@ public ResponseEntity updateBoardConfig( log.info("Admin updating board config for device: {}", deviceId); DeviceConfig updated = boardService.updateBoardConfig(deviceId, updateRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_CONFIG, + "BoardConfig", deviceId, request.getRemoteAddr()); + + return ResponseEntity.ok(updated); + } + + /** + * Ticker-only door into the config, for holders of {@code board:ticker:write} who have no + * business setting the board's coordinates. Anyone with {@code board:config:write} can + * reach the same fields through the PATCH above. + */ + @PatchMapping("/configs/{deviceId}/ticker") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_TICKER_WRITE + "')") + @Operation(operationId = "updateBoardTicker", summary = "Update a board's scrolling messages") + public ResponseEntity updateTicker( + @PathVariable UUID deviceId, + @Valid @RequestBody UpdateTickerRequest updateRequest, + HttpServletRequest request) { + + log.info("Admin updating ticker for device: {}", deviceId); + DeviceConfig updated = boardService.updateTicker(deviceId, updateRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_TICKER, + "BoardConfig", deviceId, request.getRemoteAddr()); + return ResponseEntity.ok(updated); } // ==================== Weekly Content Endpoints ==================== @GetMapping("/weekly-content") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getAllWeeklyContent() { log.debug("Admin fetching all weekly content"); return ResponseEntity.ok(boardService.getAllWeeklyContent()); } @GetMapping("/weekly-content/year/{year}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getWeeklyContentByYear(@PathVariable int year) { log.debug("Admin fetching weekly content for year: {}", year); return ResponseEntity.ok(boardService.getWeeklyContentByYear(year)); } @GetMapping("/weekly-content/{year}/{weekNumber}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") @Operation(operationId = "getAdminWeeklyContent", summary = "Fetch weekly content as an administrator") public ResponseEntity getWeeklyContent( @PathVariable int year, @@ -114,7 +155,13 @@ public ResponseEntity getWeeklyContent( return ResponseEntity.ok(boardService.getWeeklyContentOrThrow(year, weekNumber)); } + /** + * Writes quotes and jummah prayer times together — they share this endpoint, so they + * share one permission. The audit event is therefore the only record of who last moved + * a khutbah time; see {@code docs/PERMISSIONS.md} §3. + */ @PutMapping("/weekly-content/{year}/{weekNumber}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_WEEKLY_WRITE + "')") public ResponseEntity saveWeeklyContent( @PathVariable int year, @PathVariable int weekNumber, @@ -123,41 +170,54 @@ public ResponseEntity saveWeeklyContent( log.info("Admin saving weekly content for week {} of {}", weekNumber, year); WeeklyContent saved = boardService.saveWeeklyContent(year, weekNumber, contentRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.SAVE_WEEKLY_CONTENT, + "WeeklyContent", saved.getId(), request.getRemoteAddr()); + return ResponseEntity.ok(saved); } @DeleteMapping("/weekly-content/{year}/{weekNumber}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_WEEKLY_WRITE + "')") public ResponseEntity deleteWeeklyContent( @PathVariable int year, @PathVariable int weekNumber, HttpServletRequest request) { log.info("Admin deleting weekly content for week {} of {}", weekNumber, year); - boardService.deleteWeeklyContent(year, weekNumber); + UUID deletedId = boardService.deleteWeeklyContent(year, weekNumber); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_WEEKLY_CONTENT, + "WeeklyContent", deletedId, request.getRemoteAddr()); + return ResponseEntity.ok(new MessageResponse("Weekly content deleted successfully")); } // ==================== Poster Endpoints ==================== @GetMapping("/posters") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getAllPosters() { log.debug("Admin fetching all posters"); return ResponseEntity.ok(posterService.getAllPosters()); } @GetMapping("/posters/by-audience") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getPostersForAudience(@RequestParam Audience audience) { log.debug("Admin fetching posters for audience: {}", audience); return ResponseEntity.ok(posterService.getPostersForAudience(audience)); } @GetMapping("/posters/{posterId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity getPosterById(@PathVariable UUID posterId) { log.debug("Admin fetching poster: {}", posterId); return ResponseEntity.ok(posterService.getPosterById(posterId)); } @PostMapping(value = "/posters", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity createPoster( // @ModelAttribute, not @RequestParam: on a complex type @RequestParam // makes Spring look for one parameter literally named "createRequest" @@ -178,6 +238,7 @@ public ResponseEntity createPoster( } @PatchMapping("/posters/{posterId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity updatePoster( @PathVariable UUID posterId, @Valid @RequestBody UpdatePosterRequest updateRequest, @@ -192,6 +253,7 @@ public ResponseEntity updatePoster( } @PutMapping(value = "/posters/{posterId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity updatePosterImage( @PathVariable UUID posterId, @RequestParam("image") MultipartFile imageFile, @@ -206,6 +268,7 @@ public ResponseEntity updatePosterImage( } @DeleteMapping("/posters/{posterId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_POSTER_WRITE + "')") public ResponseEntity deletePoster( @PathVariable UUID posterId, HttpServletRequest request) { @@ -221,6 +284,7 @@ public ResponseEntity deletePoster( // ==================== Calendar Event Endpoints ==================== @GetMapping("/events") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") @Operation(operationId = "getBoardEvents", summary = "List every board event") public ResponseEntity> getAllEvents() { log.debug("Admin fetching all calendar events"); @@ -228,12 +292,14 @@ public ResponseEntity> getAllEvents() { } @GetMapping("/events/by-audience") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") public ResponseEntity> getEventsForAudience(@RequestParam Audience audience) { log.debug("Admin fetching calendar events for audience: {}", audience); return ResponseEntity.ok(boardService.getEventsForAudience(audience)); } @GetMapping("/events/{eventId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") @Operation(operationId = "getBoardEventById", summary = "Fetch a single board event") public ResponseEntity getEventById(@PathVariable UUID eventId) { log.debug("Admin fetching calendar event: {}", eventId); @@ -241,6 +307,7 @@ public ResponseEntity getEventById(@PathVariable UUID eventId) { } @PostMapping("/events") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_EVENT_WRITE + "')") @Operation(operationId = "createBoardEvent", summary = "Create a board event") public ResponseEntity createEvent( @Valid @RequestBody CreateCalendarEventRequest createRequest, @@ -255,6 +322,7 @@ public ResponseEntity createEvent( } @PatchMapping("/events/{eventId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_EVENT_WRITE + "')") public ResponseEntity updateEvent( @PathVariable UUID eventId, @Valid @RequestBody UpdateCalendarEventRequest updateRequest, @@ -269,6 +337,7 @@ public ResponseEntity updateEvent( } @DeleteMapping("/events/{eventId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_EVENT_WRITE + "')") public ResponseEntity deleteEvent( @PathVariable UUID eventId, HttpServletRequest request) { @@ -282,8 +351,13 @@ public ResponseEntity deleteEvent( } @PostMapping("/refresh") - public ResponseEntity refreshSignboard() { + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_REFRESH + "')") + public ResponseEntity refreshSignboard(HttpServletRequest request) { boardStream.refreshAll(); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.REFRESH_BOARDS, + "Musallah Board", null, request.getRemoteAddr()); + return ResponseEntity.ok(new MessageResponse("Refresh sent to all MusallahBoard instances")); } diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index 59d463b..d98dc3e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -8,10 +8,13 @@ import com.ibrasoft.lensbridge.dto.board.response.DeviceSummary; import com.ibrasoft.lensbridge.dto.board.response.IssueEnrollmentTokenResponse; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; -import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.audit.AuditAction; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.commands.CommandKind; import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.agent.AgentSessionRegistry; import com.ibrasoft.lensbridge.service.agent.CommandDispatcher; import com.ibrasoft.lensbridge.service.agent.EnrollmentTokenService; @@ -22,6 +25,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,10 +42,14 @@ import java.util.Optional; import java.util.UUID; +/** + * Fleet administration. Authorization is per-endpoint by permission: reading device status + * is something you hand out freely, minting an enrollment token is not, and issuing a + * command depends on which command it is. See {@code docs/PERMISSIONS.md}. + */ @RestController @RequestMapping("/api/admin/board/devices") @RequiredArgsConstructor -@PreAuthorize("hasRole('" + Role.Authority.ROOT + "')") @Slf4j public class DeviceAdminController { @@ -51,9 +59,11 @@ public class DeviceAdminController { private final CommandDispatcher commandDispatcher; private final ObjectMapper objectMapper; private final AgentSessionRegistry sessionRegistry; + private final AdminAuditService auditService; @Operation(operationId = "listDevices", summary = "List every enrolled board device") @GetMapping + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')") public ResponseEntity> list() { return ResponseEntity.ok( deviceRepository.findAll().stream().map(DeviceSummary::of).toList() @@ -67,6 +77,7 @@ public ResponseEntity> list() { content = @Content(schema = @Schema(implementation = MessageResponse.class))) }) @GetMapping("/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')") public ResponseEntity get(@PathVariable UUID deviceId) { return deviceRepository.findById(deviceId) .map(d -> ResponseEntity.ok(DeviceSummary.of(d))) @@ -78,8 +89,10 @@ public ResponseEntity get(@PathVariable UUID deviceId) { summary = "Mint a one-time token a new device can exchange for an identity") @ApiResponse(responseCode = "201", description = "Token issued; the plaintext is returned exactly once") @PostMapping("/enrollment-tokens") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_ENROLL + "')") public ResponseEntity issueEnrollmentToken( - @Valid @RequestBody IssueEnrollmentTokenRequest request) { + @Valid @RequestBody IssueEnrollmentTokenRequest request, + HttpServletRequest httpRequest) { String adminEmail = getCurrentUserEmail(); Issued issued = enrollmentTokenService.issue( request.getDisplayName(), @@ -89,6 +102,10 @@ public ResponseEntity issueEnrollmentToken( ); log.info("Admin {} issued enrollment token {} ({})", adminEmail, issued.token().getId(), request.getDisplayName()); + + auditService.logAuditEvent(adminEmail, AuditAction.ISSUE_ENROLLMENT_TOKEN, + "Device", issued.token().getId(), httpRequest.getRemoteAddr()); + return ResponseEntity.status(HttpStatus.CREATED).body( IssueEnrollmentTokenResponse.builder() .tokenId(issued.token().getId()) @@ -107,7 +124,9 @@ public ResponseEntity issueEnrollmentToken( content = @Content(schema = @Schema(implementation = MessageResponse.class))) }) @PostMapping("/{deviceId}/revoke") - public ResponseEntity revoke(@PathVariable UUID deviceId) { + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_REVOKE + "')") + public ResponseEntity revoke(@PathVariable UUID deviceId, + HttpServletRequest httpRequest) { Optional found = deviceRepository.findById(deviceId); if (found.isEmpty()) { throw new ApiResponseException(HttpStatus.NOT_FOUND, @@ -119,24 +138,46 @@ public ResponseEntity revoke(@PathVariable UUID deviceId) { deviceRepository.save(d); sessionRegistry.closeIfPresent(deviceId, CloseStatus.POLICY_VIOLATION.withReason("device_revoked")); log.warn("Device {} revoked by {}", deviceId, getCurrentUserEmail()); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.REVOKE_DEVICE, + "Device", deviceId, httpRequest.getRemoteAddr()); } return ResponseEntity.ok(DeviceSummary.of(d)); } + /** + * The required permission depends on the command's blast radius, which is only knowable + * once the body is bound - hence the authorizer bean rather than a {@code hasAuthority} + * expression. {@code chrome.reload} and {@code chrome.screenshot} are not the same + * privilege; see {@link com.ibrasoft.lensbridge.model.board.commands.CommandRisk}. + */ @Operation(operationId = "issueDeviceCommand", summary = "Queue a command for a device", description = "Returns as soon as the command is queued; delivery and execution are reported " - + "asynchronously over the agent websocket.") - @ApiResponse(responseCode = "202", description = "Command accepted and queued for delivery") + + "asynchronously over the agent websocket. The permission required depends on the " + + "command kind: board:command:benign, :disruptive, or :inspect.") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "Command accepted and queued for delivery"), + @ApiResponse(responseCode = "403", description = "Caller lacks the permission for this command kind", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) @PostMapping("/{deviceId}/commands") + @PreAuthorize("@commandAuthorizer.canIssue(authentication, #request)") public ResponseEntity issueCommand(@PathVariable UUID deviceId, - @Valid @RequestBody IssueCommandRequest request) { + @Valid @RequestBody IssueCommandRequest request, + HttpServletRequest httpRequest) { CommandIssuedResponse response = commandDispatcher.issue(deviceId, getCurrentUserEmail(), request); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.ISSUE_DEVICE_COMMAND, + "Device", deviceId, httpRequest.getRemoteAddr(), + describe(request.kind(), response.commandId())); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); } @Operation(operationId = "listDeviceCommands", summary = "Fetch the 50 most recent commands for a device") @GetMapping("/{deviceId}/commands") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')") public ResponseEntity> recentCommands(@PathVariable UUID deviceId) { List commands = commandRepository .findTop50ByDeviceIdOrderByIssuedAtDesc(deviceId) @@ -150,4 +191,12 @@ private String getCurrentUserEmail() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication != null ? authentication.getName() : "unknown"; } + + /** Audit detail for a command: the kind and its risk class, so the log distinguishes a reload from a screenshot. */ + private static String describe(String kind, UUID commandId) { + String risk = CommandKind.from(kind) + .map(k -> k.getRisk().name()) + .orElse("UNKNOWN"); + return "kind=" + kind + " risk=" + risk + " commandId=" + commandId; + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java b/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java index 386406d..bc0c082 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/audit/AuditEventDto.java @@ -21,6 +21,7 @@ public class AuditEventDto { private String adminEmail; private AuditEntityType targetEntityType; private UUID targetEntityId; + private String details; private String ipAddress; private String userAgent; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java index 9320aef..fe3c827 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java @@ -17,4 +17,5 @@ public class JwtResponse { private UUID id; private String email; private List roles; + private List permissions; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java new file mode 100644 index 0000000..ec957fb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/PermissionResponse.java @@ -0,0 +1,24 @@ +package com.ibrasoft.lensbridge.dto.auth.response; + +import com.ibrasoft.lensbridge.model.auth.Permission; + +/** + * One permission, in both the form the API accepts ({@code name}, the enum constant) and + * the form that appears in tokens and annotations ({@code authority}). + *

+ * {@code domain} is the namespace segment {@code board}, {@code media}, {@code iam}, + * {@code audit} which is enough for the console to group the picker without hardcoding + * a list of groups that would then drift. + */ +public record PermissionResponse( + String name, + String authority, + String domain +) { + public static PermissionResponse of(Permission permission) { + String authority = permission.getAuthority(); + int firstColon = authority.indexOf(':'); + String domain = firstColon > 0 ? authority.substring(0, firstColon) : authority; + return new PermissionResponse(permission.name(), authority, domain); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java new file mode 100644 index 0000000..dce1245 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java @@ -0,0 +1,26 @@ +package com.ibrasoft.lensbridge.dto.auth.response; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; + +import java.util.List; + +/** + * A role plus what it actually confers, so the admin console can show an + * operator what + * they are about to hand someone instead of an opaque enum name. + */ +public record RoleDefinitionResponse( + String name, + String description, + List permissions) { + public static RoleDefinitionResponse of(Role role) { + return new RoleDefinitionResponse( + role.name(), + role.getDescription(), + role.getPermissions().stream() + .map(Permission::getAuthority) + .sorted() + .toList()); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java index d3540c8..ef6c472 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/TokenValidationResponse.java @@ -16,4 +16,5 @@ public class TokenValidationResponse { private String email; private boolean verified; private List roles; + private List permissions; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java index 387b2da..3683091 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/UserInfoResponse.java @@ -5,13 +5,14 @@ import java.util.UUID; import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; import lombok.Data; import com.ibrasoft.lensbridge.model.auth.Role; @Data @AllArgsConstructor -@lombok.NoArgsConstructor +@NoArgsConstructor public class UserInfoResponse { private UUID id; private String firstName; @@ -20,4 +21,11 @@ public class UserInfoResponse { private String studentNumber; private boolean isVerified; private Set roles; + private List directPermissions; + private List effectivePermissions; + + public UserInfoResponse(UUID id, String firstName, String lastName, String email, + String studentNumber, boolean isVerified, Set roles) { + this(id, firstName, lastName, email, studentNumber, isVerified, roles, List.of(), List.of()); + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java new file mode 100644 index 0000000..5e2ad65 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateTickerRequest.java @@ -0,0 +1,25 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import lombok.*; + +import java.util.List; + +/** + * The editorial slice of {@code DeviceConfig}: the scrolling message ticker. + *

+ * Split out from {@link UpdateBoardConfigRequest} so ticker copy can be edited with + * {@code board:ticker:write} alone. The rest of the config sets the board's coordinates, + * which silently change every prayer time on it — not something a copy edit should reach. + * Both fields are nullable and ignored when null, matching the PATCH semantics of the + * config endpoint. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateTickerRequest { + + private Boolean enableScrollingMessage; + + private List scrollingMessages; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java index 7f86d3e..a4ada93 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java @@ -25,7 +25,22 @@ public enum AuditAction { CREATE_POSTER("Create Poster"), UPDATE_POSTER("Update Poster"), DELETE_POSTER("Delete Poster"), - + + // Board Content Actions (Musallah Board) + // SAVE_WEEKLY_CONTENT is the only record of who last changed a jummah time, since + // quotes and prayer times share one permission and one endpoint. + SAVE_WEEKLY_CONTENT("Save Weekly Content"), + DELETE_WEEKLY_CONTENT("Delete Weekly Content"), + UPDATE_BOARD_CONFIG("Update Board Config"), + UPDATE_BOARD_TICKER("Update Board Ticker"), + REFRESH_BOARDS("Refresh All Boards"), + + // Device Fleet Actions (Musallah Board) + ISSUE_ENROLLMENT_TOKEN("Issue Device Enrollment Token"), + REVOKE_DEVICE("Revoke Device"), + ISSUE_DEVICE_COMMAND("Issue Device Command"), + + // User Management Actions PROMOTE_USER("Promote User to Admin"), DEMOTE_USER("Remove Admin Role"), @@ -45,6 +60,8 @@ public enum AuditAction { ADD_USER_ROLE("Add User Role"), REMOVE_USER_ROLE("Remove User Role"), + GRANT_PERMISSION("Grant Direct Permission"), + REVOKE_PERMISSION("Revoke Direct Permission"), ADD_USER("Add User"), REMOVE_USER("Remove User"), diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java index a026384..1090874 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEntityType.java @@ -4,6 +4,7 @@ public enum AuditEntityType { USER("User"), UPLOAD("Upload"), MUSALLAH_BOARD("Musallah Board"), + DEVICE("Device"), EVENT("Event"); diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java index bd09d2a..43c8957 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditEvent.java @@ -29,6 +29,8 @@ @Entity @Table(name = "audit_events") public class AuditEvent { + public static final int DETAILS_MAX = 512; + @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @@ -50,6 +52,9 @@ public class AuditEvent { private UUID targetEntityId; + @Column(length = DETAILS_MAX) + private String details; + private String ipAddress; private String userAgent; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java new file mode 100644 index 0000000..7d48f4d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java @@ -0,0 +1,116 @@ +package com.ibrasoft.lensbridge.model.auth; + +import org.springframework.security.core.GrantedAuthority; + +/** + * The unit of authorization. Every {@code @PreAuthorize} in this codebase checks a + * permission via {@code hasAuthority(...)}; none of them check a role name. + * {@link Role} exists only as a named bundle of these. + *

+ * Authority strings are namespaced {@code domain:resource:verb} and carry no + * {@code ROLE_} prefix, which is what distinguishes them from {@link Role} authorities + * in the same granted-authority collection. + *

+ * The literals live in {@link Authority} and are referenced from the constructor, rather + * than the other way round, because {@code @PreAuthorize} needs compile-time constants. + * Declaring them once here means the annotation sites and the enum cannot drift apart. + */ +public enum Permission implements GrantedAuthority { + + // ==================== Legacy media sharing ==================== + // These name what USER and ADMIN already do. Media authorization is unchanged; + // it is only expressed as a bundle now so it composes like everything else. + + MEDIA_UPLOAD_SELF(Authority.MEDIA_UPLOAD_SELF), + MEDIA_UPLOAD_MODERATE(Authority.MEDIA_UPLOAD_MODERATE), + MEDIA_UPLOAD_READ(Authority.MEDIA_UPLOAD_READ), + MEDIA_EVENT_WRITE(Authority.MEDIA_EVENT_WRITE), + + // ==================== MusallahBoard content ==================== + + BOARD_CONTENT_READ(Authority.BOARD_CONTENT_READ), + BOARD_POSTER_WRITE(Authority.BOARD_POSTER_WRITE), + BOARD_EVENT_WRITE(Authority.BOARD_EVENT_WRITE), + /** Quotes and jummah times together — they share one endpoint. See docs/PERMISSIONS.md §3. */ + BOARD_WEEKLY_WRITE(Authority.BOARD_WEEKLY_WRITE), + /** Ticker copy only. Deliberately not BOARD_CONFIG_WRITE, which can move the board's coordinates. */ + BOARD_TICKER_WRITE(Authority.BOARD_TICKER_WRITE), + + // ==================== MusallahBoard configuration ==================== + + BOARD_CONFIG_READ(Authority.BOARD_CONFIG_READ), + BOARD_CONFIG_WRITE(Authority.BOARD_CONFIG_WRITE), + BOARD_REFRESH(Authority.BOARD_REFRESH), + + // ==================== Fleet ==================== + + BOARD_DEVICE_READ(Authority.BOARD_DEVICE_READ), + /** Mints a credential an unknown machine can exchange for a device identity. */ + BOARD_DEVICE_ENROLL(Authority.BOARD_DEVICE_ENROLL), + BOARD_DEVICE_REVOKE(Authority.BOARD_DEVICE_REVOKE), + /** Required to SUBSCRIBE to /topic/devices/** — command output lands there. */ + BOARD_TELEMETRY_SUBSCRIBE(Authority.BOARD_TELEMETRY_SUBSCRIBE), + + // ==================== Device commands, split by blast radius ==================== + + /** Recoverable, no data egress: chrome.reload, config.refresh. */ + BOARD_COMMAND_BENIGN(Authority.BOARD_COMMAND_BENIGN), + /** Takes a physical display down: kiosk.restart, system.reboot. */ + BOARD_COMMAND_DISRUPTIVE(Authority.BOARD_COMMAND_DISRUPTIVE), + /** Data egress: chrome.screenshot returns an image of a screen in a prayer space; logs.tail leaks internals. */ + BOARD_COMMAND_INSPECT(Authority.BOARD_COMMAND_INSPECT), + + // ==================== Identity and audit ==================== + + IAM_USER_READ(Authority.IAM_USER_READ), + IAM_USER_WRITE(Authority.IAM_USER_WRITE), + /** The escalation vector. ROOT only. */ + IAM_ROLE_GRANT(Authority.IAM_ROLE_GRANT), + AUDIT_READ(Authority.AUDIT_READ); + + private final String authority; + + Permission(String authority) { + this.authority = authority; + } + + @Override + public String getAuthority() { + return authority; + } + + /** + * Compile-time constants for {@code @PreAuthorize("hasAuthority(Permission.Authority.X)")}. + * Mirrors the {@link Role.Authority} idiom. + */ + public interface Authority { + String MEDIA_UPLOAD_SELF = "media:upload:self"; + String MEDIA_UPLOAD_MODERATE = "media:upload:moderate"; + String MEDIA_UPLOAD_READ = "media:upload:read"; + String MEDIA_EVENT_WRITE = "media:event:write"; + + String BOARD_CONTENT_READ = "board:content:read"; + String BOARD_POSTER_WRITE = "board:poster:write"; + String BOARD_EVENT_WRITE = "board:event:write"; + String BOARD_WEEKLY_WRITE = "board:weekly:write"; + String BOARD_TICKER_WRITE = "board:ticker:write"; + + String BOARD_CONFIG_READ = "board:config:read"; + String BOARD_CONFIG_WRITE = "board:config:write"; + String BOARD_REFRESH = "board:refresh"; + + String BOARD_DEVICE_READ = "board:device:read"; + String BOARD_DEVICE_ENROLL = "board:device:enroll"; + String BOARD_DEVICE_REVOKE = "board:device:revoke"; + String BOARD_TELEMETRY_SUBSCRIBE = "board:telemetry:subscribe"; + + String BOARD_COMMAND_BENIGN = "board:command:benign"; + String BOARD_COMMAND_DISRUPTIVE = "board:command:disruptive"; + String BOARD_COMMAND_INSPECT = "board:command:inspect"; + + String IAM_USER_READ = "iam:user:read"; + String IAM_USER_WRITE = "iam:user:write"; + String IAM_ROLE_GRANT = "iam:role:grant"; + String AUDIT_READ = "audit:read"; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java index d14554f..016efba 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Role.java @@ -1,12 +1,54 @@ package com.ibrasoft.lensbridge.model.auth; +import java.util.Set; + import org.springframework.security.core.GrantedAuthority; +/** + * A named bundle of {@link Permission}s. Roles are what you assign to a person, because + * "make them a board editor" is how humans think — but nothing in this codebase branches + * on a role name. Every {@code @PreAuthorize} checks a permission. + *

+ * The bundles themselves are in {@link RolePermissions}. + *

+ * Role authorities are still emitted as {@code ROLE_*} alongside the expanded permission + * set, so any surviving {@code hasRole(...)} check keeps working. + */ public enum Role implements GrantedAuthority { - USER, ADMIN, ROOT; + + // ---------- Legacy media sharing ---------- + USER("Upload and manage your own media submissions"), + ADMIN("Moderate media submissions and read the audit log"), + + // ---------- MusallahBoard ---------- + BOARD_VIEWER("View board content, config, and device status; change nothing"), + BOARD_EDITOR("Create and edit everything that appears on a board"), + BOARD_ADMIN("Full control of boards and devices, including enrollment and remote commands"), + + // ---------- Everything ---------- + ROOT("Unrestricted, including granting roles to other users"); + + private final String description; + + Role(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /** The permissions this role confers. Never null; may not be modified. */ + public Set getPermissions() { + return RolePermissions.of(this); + } // String constants for @PreAuthorize("hasRole('" + Role.Authority.ADMIN + "')") // Spring Security's hasRole() prepends ROLE_ automatically, so these are just the name. + // These will be removed once media-related + // features are stripped from the codebase - they are here purely for back-compat until I fully + // transition over to Minbar + @Deprecated public interface Authority { String USER = "USER"; String ADMIN = "ADMIN"; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java new file mode 100644 index 0000000..da203a9 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java @@ -0,0 +1,99 @@ +package com.ibrasoft.lensbridge.model.auth; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +/** + * The role -> permission bundles. This is the whole authorization policy of the system, + * in one file, on purpose: if you want to know what a role can do, you read this and + * nothing else. + *

+ * Bundles live here rather than in {@link Role}'s constructor because the JLS forbids an + * enum constant's initializer from referencing another constant of the same enum, and the + * bundles are defined by composition ({@code BOARD_ADMIN = BOARD_EDITOR ∪ …}). + *

+ * There is deliberately no Spring {@code RoleHierarchy} bean. Inheritance is set union + * performed here, once, at class-init time — a hierarchy string in security config could + * silently disagree with these sets. + */ +final class RolePermissions { + + private static final Map> BUNDLES = new EnumMap<>(Role.class); + + static { + // ---------- Legacy media sharing ---------- + + Set user = EnumSet.of( + Permission.MEDIA_UPLOAD_SELF); + + Set admin = union(user, EnumSet.of( + Permission.MEDIA_UPLOAD_MODERATE, + Permission.MEDIA_UPLOAD_READ, + Permission.MEDIA_EVENT_WRITE, + Permission.AUDIT_READ)); + + // ---------- MusallahBoard ---------- + + // Read-only across the whole board surface. The role you hand someone so they can + // tell you a board is offline without being able to touch it. + Set boardViewer = EnumSet.of( + Permission.BOARD_CONTENT_READ, + Permission.BOARD_CONFIG_READ, + Permission.BOARD_DEVICE_READ, + Permission.BOARD_TELEMETRY_SUBSCRIBE); + + // Can change anything that appears on a board, and nothing about the board itself. + Set boardEditor = union(boardViewer, EnumSet.of( + Permission.BOARD_POSTER_WRITE, + Permission.BOARD_EVENT_WRITE, + Permission.BOARD_WEEKLY_WRITE, + Permission.BOARD_TICKER_WRITE, + Permission.BOARD_REFRESH)); + + // Owns the board product. Note the absence of everything under iam: — owning the + // boards is not owning the org's accounts. + Set boardAdmin = union(boardEditor, EnumSet.of( + Permission.BOARD_CONFIG_WRITE, + Permission.BOARD_REFRESH, + Permission.BOARD_COMMAND_BENIGN, + Permission.BOARD_COMMAND_DISRUPTIVE, + Permission.BOARD_DEVICE_ENROLL, + Permission.BOARD_DEVICE_REVOKE, + Permission.BOARD_COMMAND_INSPECT, + Permission.AUDIT_READ)); + + BUNDLES.put(Role.USER, user); + BUNDLES.put(Role.ADMIN, admin); + BUNDLES.put(Role.BOARD_VIEWER, boardViewer); + BUNDLES.put(Role.BOARD_EDITOR, boardEditor); + BUNDLES.put(Role.BOARD_ADMIN, boardAdmin); + + // ROOT is allOf rather than an explicit list so a newly added permission is held by + // root on deploy. For a single-org deployment that is correct: the alternative locks + // the owner out of every new feature until someone remembers to edit this file. + // Revisit if Minbar ever becomes multi-tenant. + BUNDLES.put(Role.ROOT, EnumSet.allOf(Permission.class)); + + for (Role role : Role.values()) { + if (!BUNDLES.containsKey(role)) { + throw new IllegalStateException("Role " + role + " has no permission bundle"); + } + } + } + + private RolePermissions() { + } + + static Set of(Role role) { + return Collections.unmodifiableSet(BUNDLES.get(role)); + } + + private static Set union(Set a, Set b) { + EnumSet merged = EnumSet.copyOf(a); + merged.addAll(b); + return merged; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java index 580eb32..4ed30e4 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/User.java @@ -17,6 +17,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -69,6 +70,12 @@ public class User { @CollectionTable(name = "user_roles") @Column(name = "role", nullable = false) private Set roles = new HashSet<>(); + + @ElementCollection(targetClass = Permission.class, fetch = FetchType.LAZY) + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_permissions", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "permission", nullable = false) + private Set directPermissions = new HashSet<>(); @Column(nullable = true) private Instant verifiedAt; @@ -80,6 +87,7 @@ public User(String firstName, String lastName, String studentNumber, String emai this.email = email; this.passwordHash = passwordHash; this.roles = new HashSet<>(); + this.directPermissions = new HashSet<>(); } @JsonIgnore @@ -100,6 +108,15 @@ public boolean hasRole(Role role) { return roles != null && roles.contains(role); } + public void addDirectPermission(Permission permission) { + if (directPermissions == null) directPermissions = new HashSet<>(); + directPermissions.add(permission); + } + + public boolean hasDirectPermission(Permission permission) { + return directPermissions != null && directPermissions.contains(permission); + } + public boolean isVerified() { return verifiedAt != null; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java new file mode 100644 index 0000000..94cd69f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandKind.java @@ -0,0 +1,65 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.ibrasoft.lensbridge.model.auth.Permission; + +import java.util.Arrays; +import java.util.Optional; + +/** + * The registry of issuable command kinds and their {@link CommandRisk}. + *

+ * This replaces the hardcoded {@code KNOWN_KINDS} string set that used to live in + * {@code CommandDispatcher}, which duplicated the {@code @JsonSubTypes} registration on + * {@link CommandPayload} with nothing keeping the two in step. Adding a command kind is + * now: new record + new {@code @Type} entry + a constant here (which forces a risk + * classification) + agent-side handler. {@code CommandKindTest} fails if the wire strings + * here and the Jackson subtypes ever diverge. + *

+ * The dispatcher never deserializes the payload into a {@link CommandPayload}; it stores + * the raw JSON and hands it to the agent so classification has to hang off the wire + * string rather than the payload type. + */ +public enum CommandKind { + + CHROME_RELOAD("chrome.reload", CommandRisk.BENIGN), + CONFIG_REFRESH("config.refresh", CommandRisk.BENIGN), + + KIOSK_RESTART("kiosk.restart", CommandRisk.DISRUPTIVE), + SYSTEM_REBOOT("system.reboot", CommandRisk.DISRUPTIVE), + + CHROME_SCREENSHOT("chrome.screenshot", CommandRisk.INSPECT), + LOGS_TAIL("logs.tail", CommandRisk.INSPECT); + + private final String wireName; + private final CommandRisk risk; + + CommandKind(String wireName, CommandRisk risk) { + this.wireName = wireName; + this.risk = risk; + } + + /** The dotted discriminator sent over the wire, e.g. {@code chrome.reload}. */ + public String getWireName() { + return wireName; + } + + public CommandRisk getRisk() { + return risk; + } + + public Permission getRequiredPermission() { + return risk.getRequiredPermission(); + } + + public static Optional from(String wireName) { + if (wireName == null) return Optional.empty(); + return Arrays.stream(values()) + .filter(k -> k.wireName.equals(wireName)) + .findFirst(); + } + + @Override + public String toString() { + return wireName; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java new file mode 100644 index 0000000..a64bf54 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/commands/CommandRisk.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.ibrasoft.lensbridge.model.auth.Permission; + +/** + * Blast radius of a device command, and the permission required to issue one. + *

+ * These are separately grantable because they are not comparable risks: a reload is a + * page refresh, a reboot takes a display in a prayer space down for minutes, and a + * screenshot is a camera pointed at whatever is on that display. Holding one implies + * nothing about the others + */ +public enum CommandRisk { + + /* Harmless commands, typically configuration-related */ + BENIGN(Permission.BOARD_COMMAND_BENIGN), + + /* Commands which may result in brief downtime */ + DISRUPTIVE(Permission.BOARD_COMMAND_DISRUPTIVE), + + /* Commands - typically harmless - which fetch information about the board state and thus require appropriate auditing + */ + INSPECT(Permission.BOARD_COMMAND_INSPECT); + + private final Permission requiredPermission; + + CommandRisk(Permission requiredPermission) { + this.requiredPermission = requiredPermission; + } + + public Permission getRequiredPermission() { + return requiredPermission; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java index 24dcc51..10211ab 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/auth/UserRepository.java @@ -1,11 +1,17 @@ package com.ibrasoft.lensbridge.repository.auth; +import java.util.Collection; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; @Repository @@ -13,6 +19,22 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + /** + * Loads {@code directPermissions} on its own rather than eagerly with the entity, so + * the two collection tables are never joined in the same query. See the field comment + * on {@link User#getDirectPermissions()}. + */ + @Query("select p from User u join u.directPermissions p where u.id = :userId") + Set findDirectPermissions(@Param("userId") UUID userId); + + /** Ids of users holding {@code role}. Used to check nobody is about to be locked out. */ + @Query("select u.id from User u join u.roles r where r in :roles") + Set findIdsByRoleIn(@Param("roles") Collection roles); + + /** Ids of users holding {@code permission} as a direct grant, ignoring anything roles confer. */ + @Query("select u.id from User u join u.directPermissions p where p = :permission") + Set findIdsByDirectPermission(@Param("permission") Permission permission); + Boolean existsByEmail(String email); Boolean existsByStudentNumber(String studentNumber); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java b/src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java new file mode 100644 index 0000000..46fc20b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/CommandAuthorizer.java @@ -0,0 +1,53 @@ +package com.ibrasoft.lensbridge.security; + +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.board.commands.CommandKind; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +/** + * Authorizes a device command by its {@code kind}, for use from + * {@code @PreAuthorize("@commandAuthorizer.canIssue(authentication, #request)")}. + *

+ * The required permission depends on the request body, which a plain {@code hasAuthority} + * expression cannot see. Method security runs after argument resolution, so a bean call in + * the expression can — and this keeps the rule declarative at the endpoint instead of + * buried in the dispatcher. + */ +@Component +@Slf4j +public class CommandAuthorizer { + + public boolean canIssue(Authentication authentication, IssueCommandRequest request) { + if (authentication == null || !authentication.isAuthenticated() || request == null) { + return false; + } + + // An unrecognised kind is deliberately allowed through: CommandDispatcher rejects it + // with 400, which is a more useful answer to a typo than 403. Nothing is issued either way. + CommandKind kind = CommandKind.from(request.kind()).orElse(null); + if (kind == null) { + return true; + } + + Permission required = kind.getRequiredPermission(); + boolean allowed = hasAuthority(authentication, required); + if (!allowed) { + log.warn("Denied command {} for {}: missing {}", + kind.getWireName(), authentication.getName(), required.getAuthority()); + } + return allowed; + } + + private static boolean hasAuthority(Authentication authentication, Permission permission) { + for (GrantedAuthority granted : authentication.getAuthorities()) { + if (permission.getAuthority().equals(granted.getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java b/src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java new file mode 100644 index 0000000..fb358b5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/AuthorityResolver.java @@ -0,0 +1,58 @@ +package com.ibrasoft.lensbridge.security.services; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; + +import lombok.RequiredArgsConstructor; + +/** + * Expands a {@link User} into the authority set Spring Security checks against: + *

+ *   authorities = roles (as ROLE_*) ∪ ⋃(role bundles) ∪ direct grants
+ * 
+ */ +@Service +@RequiredArgsConstructor +public class AuthorityResolver { + + private final UserRepository userRepository; + + public Collection resolveAuthorities(User user) { + Set authorities = new HashSet<>(resolvePermissions(user)); + authorities.addAll(user.getRoles()); + return authorities; + } + + public Set resolvePermissions(User user) { + Set permissions = EnumSet.noneOf(Permission.class); + for (Role role : user.getRoles()) { + permissions.addAll(role.getPermissions()); + } + permissions.addAll(directGrants(user.getId())); + return permissions; + } + + public Set resolvePermissions(UUID userId) { + return userRepository.findById(userId) + .map(this::resolvePermissions) + .orElseGet(() -> EnumSet.noneOf(Permission.class)); + } + + private Set directGrants(UUID userId) { + if (userId == null) { + return Set.of(); // unsaved entity, e.g. in tests + } + return userRepository.findDirectPermissions(userId); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java index 1ac5c7a..bcd4131 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/services/UserDetailsServiceImpl.java @@ -1,7 +1,6 @@ package com.ibrasoft.lensbridge.security.services; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -10,25 +9,24 @@ import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.repository.auth.UserRepository; -import java.util.ArrayList; -import java.util.List; +import lombok.RequiredArgsConstructor; @Service +@RequiredArgsConstructor public class UserDetailsServiceImpl implements UserDetailsService { - @Autowired - UserRepository userRepository; + private final UserRepository userRepository; + private final AuthorityResolver authorityResolver; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); - List authorities = new ArrayList<>(user.getRoles()); - - return org.springframework.security.core.userdetails.User + // No collision with lensbridge.model.auth.User + return org.springframework.security.core.userdetails.User .withUsername(user.getEmail()) .password(user.getPassword()) - .authorities(authorities) + .authorities(authorityResolver.resolveAuthorities(user)) .disabled(!user.isVerified()) .build(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java index 70cef5b..a76ef8a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/AdminAuditService.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -39,17 +40,33 @@ public AuditEvent logAuditEvent(AuditEvent event) { } public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, String IPAddress) { + return logAuditEvent(adminEmail, action, entityType, entityId, IPAddress, null); + } + + /** + * @param details short free-text describing what was done, for actions where + * the action name alone is ambiguous (e.g. the command kind on + * {@link AuditAction#ISSUE_DEVICE_COMMAND}). Truncated to the column width. + */ + public AuditEvent logAuditEvent(String adminEmail, AuditAction action, String entityType, UUID entityId, + String IPAddress, String details) { AuditEvent event = AuditEvent.builder() .admin(userRepository.findByEmail(adminEmail).orElseThrow(() -> new IllegalArgumentException("Admin user not found: " + adminEmail))) .action(action) .targetEntityType(toEntityType(entityType)) .targetEntityId(entityId) .timestamp(Instant.now()) + .details(truncate(details)) .ipAddress(IPAddress) .build(); return logAuditEvent(event); } + private static String truncate(String details) { + if (details == null || details.length() <= AuditEvent.DETAILS_MAX) return details; + return details.substring(0, AuditEvent.DETAILS_MAX); + } + public Page getAllAuditEvents(Pageable pageable) { return auditEventRepository.findAllByOrderByTimestampDesc(pageable).map(this::toDto); } @@ -82,7 +99,7 @@ private AuditEventDto toDto(AuditEvent event) { return new AuditEventDto( event.getId(), event.getAction(), event.getTimestamp(), adminId, adminName, adminEmail, - event.getTargetEntityType(), event.getTargetEntityId(), + event.getTargetEntityType(), event.getTargetEntityId(), event.getDetails(), event.getIpAddress(), event.getUserAgent()); } @@ -100,7 +117,9 @@ private AuditEntityType toEntityType(String entityType) { return switch (entityType.toLowerCase()) { case "user" -> AuditEntityType.USER; case "upload" -> AuditEntityType.UPLOAD; - case "poster", "calendarevent", "boardevent", "musallah board" -> AuditEntityType.MUSALLAH_BOARD; + case "poster", "calendarevent", "boardevent", "musallah board", + "boardconfig", "weeklycontent" -> AuditEntityType.MUSALLAH_BOARD; + case "device" -> AuditEntityType.DEVICE; default -> AuditEntityType.EVENT; }; } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index e9fa390..83bbf58 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateTickerRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; @@ -80,6 +81,16 @@ public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest re return saved; } + public DeviceConfig updateTicker(UUID deviceId, UpdateTickerRequest request) { + DeviceConfig existing = getBoardConfigOrThrow(deviceId); + Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); + Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); + DeviceConfig saved = boardConfigRepository.save(existing); + log.info("Updated ticker for device: {}", deviceId); + boardStream.configChanged(deviceId); + return saved; + } + public List getAllBoardConfigs() { return boardConfigRepository.findAll(); } @@ -161,14 +172,17 @@ public WeeklyContent saveWeeklyContent(int year, int weekNumber, WeeklyContentRe return saved; } - public void deleteWeeklyContent(int year, int weekNumber) { + /** @return the id of the deleted row, so callers can record what was destroyed. */ + public UUID deleteWeeklyContent(int year, int weekNumber) { WeeklyContent content = weeklyContentRepository.findByYearAndWeekNumber(year, weekNumber) .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Weekly content not found for week " + weekNumber + " of " + year))); + UUID deletedId = content.getId(); weeklyContentRepository.delete(content); log.info("Deleted weekly content for week {} of {}", weekNumber, year); boardStream.contentChanged("weekly-content"); + return deletedId; } // ==================== Events ==================== diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index 6be9630..8af4f1c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -1,24 +1,33 @@ package com.ibrasoft.lensbridge.service; import java.time.Instant; +import java.util.Arrays; +import java.util.EnumSet; import java.util.HashSet; +import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.auth.VerificationToken; import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.security.services.AuthorityResolver; import com.ibrasoft.lensbridge.security.services.EmailService; import lombok.RequiredArgsConstructor; @@ -34,14 +43,32 @@ public class UserService { private final EmailService emailService; private final VerificationTokenService verificationTokenService; private final RefreshTokenService refreshTokenService; + private final AuthorityResolver authorityResolver; @Value("${frontend.baseurl}") private String frontendBaseUrl; public Page getAllUsers(Pageable pageable) { - return userRepository.findAll(pageable).map(u -> - new UserInfoResponse(u.getId(), u.getFirstName(), u.getLastName(), - u.getEmail(), u.getStudentNumber(), u.isVerified(), u.getRoles())); + return userRepository.findAll(pageable).map(u -> { + // Fetched once and used for both lists. Going through AuthorityResolver here + // would re-query the same direct grants, making a 50-row page 101 queries. + Set direct = userRepository.findDirectPermissions(u.getId()); + + Set effective = EnumSet.noneOf(Permission.class); + for (Role role : u.getRoles()) { + effective.addAll(role.getPermissions()); + } + effective.addAll(direct); + + return new UserInfoResponse( + u.getId(), u.getFirstName(), u.getLastName(), + u.getEmail(), u.getStudentNumber(), u.isVerified(), u.getRoles(), + authorities(direct), authorities(effective)); + }); + } + + private static List authorities(Set permissions) { + return permissions.stream().map(Permission::getAuthority).sorted().toList(); } public Optional findById(UUID id) { @@ -117,26 +144,215 @@ public User verifyDirectly(UUID userId) { return saved; } - public User addRole(UUID userId, Role role) { + // ==================== Grants ==================== + // + // Three rules apply to every change below, and they are the difference between a + // permission system and a suggestion: + // + // 1. Nobody edits their own grants. Otherwise iam:role:grant is self-service ROOT. + // 2. Nobody hands out what they do not hold. Otherwise iam:role:grant is ROOT anyway, + // one step removed. + // 3. Nobody takes away what they do not hold either. Rule 2 alone is not enough: + // demotion is an escalation path. Someone holding a direct iam:role:grant could + // otherwise strip ROLE_ROOT from every root account one at a time — rule 4 never + // fires, because their own direct grant keeps the holder set non-empty — and end + // as the only administrator left standing. + // 4. The last holder of iam:role:grant cannot be stripped of it, or the org locks + // itself out of its own admin console with no way back in short of a DB edit. + + public User addRole(String actingEmail, UUID userId, Role role) { User user = findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "roles"); + denyEscalation(acting, role.getPermissions(), "role " + role); + if (user.hasRole(role)) { throw new IllegalArgumentException("User already has role: " + role); } user.addRole(role); + log.info("{} granted role {} to {}", actingEmail, role, user.getEmail()); return saveUser(user); } - public User removeRole(UUID userId, Role role) { + public User removeRole(String actingEmail, UUID userId, Role role) { User user = findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "roles"); + denyDeprivation(acting, role.getPermissions(), "role " + role); + if (!user.hasRole(role)) { throw new IllegalArgumentException("User does not have role: " + role); } + denyLastGrantHolderRemovalIfLosingIt(user, withoutRole(user, role)); user.getRoles().remove(role); + log.info("{} removed role {} from {}", actingEmail, role, user.getEmail()); return saveUser(user); } + /** + * Grants a single permission on top of whatever the user's roles already confer. + *

+ * {@code @Transactional} is load-bearing: {@code User.directPermissions} is LAZY, and + * without an enclosing transaction {@code findById} hands back a detached entity whose + * collection throws on first touch. {@code open-in-view} is false in prod and true in + * dev, so the failure would appear only in production. + */ + @Transactional + public User grantPermission(String actingEmail, UUID userId, Permission permission) { + User user = findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "permissions"); + denyEscalation(acting, Set.of(permission), permission.getAuthority()); + + if (user.hasDirectPermission(permission)) { + throw new IllegalArgumentException("User already has permission: " + permission.getAuthority()); + } + user.addDirectPermission(permission); + log.info("{} granted permission {} to {}", actingEmail, permission.getAuthority(), user.getEmail()); + return saveUser(user); + } + + /** See {@link #grantPermission} on why this is transactional. */ + @Transactional + public User revokePermission(String actingEmail, UUID userId, Permission permission) { + User user = findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + User acting = requireActingUser(actingEmail); + + denySelfModification(acting, userId, "permissions"); + denyDeprivation(acting, Set.of(permission), permission.getAuthority()); + + if (!user.hasDirectPermission(permission)) { + throw new IllegalArgumentException( + "User does not have a direct grant of: " + permission.getAuthority() + + " (a permission conferred by a role is removed by removing the role)"); + } + denyLastGrantHolderRemovalIfLosingIt(user, withoutDirectPermission(user, permission)); + user.getDirectPermissions().remove(permission); + log.info("{} revoked permission {} from {}", actingEmail, permission.getAuthority(), user.getEmail()); + return saveUser(user); + } + + private User requireActingUser(String actingEmail) { + return findByEmail(actingEmail) + .orElseThrow(() -> new SecurityException("Acting user not found: " + actingEmail)); + } + + private void denySelfModification(User acting, UUID targetUserId, String what) { + if (Objects.equals(acting.getId(), targetUserId)) { + throw new SecurityException("You cannot change your own " + what); + } + } + + private void denyEscalation(User acting, Set requested, String label) { + Set held = authorityResolver.resolvePermissions(acting); + Set missing = new HashSet<>(requested); + missing.removeAll(held); + if (!missing.isEmpty()) { + throw new SecurityException("You cannot grant " + label + + " because you do not hold: " + missing.stream() + .map(Permission::getAuthority).sorted().toList()); + } + } + + /** + * The mirror of {@link #denyEscalation}. Taking a capability away is as much a route to + * sole control as handing one out, so removals are held to the same bar. + */ + private void denyDeprivation(User acting, Set requested, String label) { + Set held = authorityResolver.resolvePermissions(acting); + Set missing = new HashSet<>(requested); + missing.removeAll(held); + if (!missing.isEmpty()) { + throw new SecurityException("You cannot remove " + label + + " because you do not hold: " + missing.stream() + .map(Permission::getAuthority).sorted().toList()); + } + } + + /** + * Refuses to leave nobody able to grant roles. + *

+ * Only fires when the target would actually lose {@code iam:role:grant}: a ROOT who also + * carries it as a direct grant keeps it after {@code ROLE_ROOT} is removed, and that + * removal is nobody's lockout. Holders are counted by role bundle and by direct grant, + * so this stays correct if the permission is ever added to a role other than ROOT. + * + * @param remaining the target's permissions as they would be after the removal + */ + private void denyLastGrantHolderRemovalIfLosingIt(User target, Set remaining) { + if (remaining.contains(Permission.IAM_ROLE_GRANT)) { + return; // keeps the capability by another path + } + if (!effectivePermissions(target).contains(Permission.IAM_ROLE_GRANT)) { + return; // never had it + } + + Set grantingRoles = Arrays.stream(Role.values()) + .filter(r -> r.getPermissions().contains(Permission.IAM_ROLE_GRANT)) + .collect(Collectors.toSet()); + + Set holders = new HashSet<>(); + if (!grantingRoles.isEmpty()) { + holders.addAll(userRepository.findIdsByRoleIn(grantingRoles)); + } + holders.addAll(userRepository.findIdsByDirectPermission(Permission.IAM_ROLE_GRANT)); + holders.remove(target.getId()); + + if (holders.isEmpty()) { + throw new SecurityException("Refusing to remove " + Permission.IAM_ROLE_GRANT.getAuthority() + + " from the last user holding it — grant it to someone else first"); + } + } + + /** + * Roles expanded plus direct grants. Computed here rather than through + * {@code AuthorityResolver} so the "held before" and "held after" sides of the lockout + * check are derived the same way and cannot disagree. + */ + private Set effectivePermissions(User target) { + Set permissions = EnumSet.noneOf(Permission.class); + for (Role role : target.getRoles()) { + permissions.addAll(role.getPermissions()); + } + permissions.addAll(directPermissionsOf(target)); + return permissions; + } + + /** The target's effective permissions with {@code role} taken away, without mutating them. */ + private Set withoutRole(User target, Role role) { + Set remaining = EnumSet.noneOf(Permission.class); + for (Role kept : target.getRoles()) { + if (kept != role) remaining.addAll(kept.getPermissions()); + } + remaining.addAll(directPermissionsOf(target)); + return remaining; + } + + /** The target's effective permissions with a direct grant taken away. */ + private Set withoutDirectPermission(User target, Permission permission) { + Set remaining = EnumSet.noneOf(Permission.class); + for (Role kept : target.getRoles()) { + remaining.addAll(kept.getPermissions()); + } + for (Permission direct : directPermissionsOf(target)) { + if (direct != permission) remaining.add(direct); + } + return remaining; + } + + private Set directPermissionsOf(User target) { + if (target.getId() == null) return Set.of(); // unsaved entity + Set direct = userRepository.findDirectPermissions(target.getId()); + return direct == null ? Set.of() : direct; + } + public void requestPasswordReset(String email) { userRepository.findByEmail(email).ifPresent(user -> { String token = verificationTokenService.generatePasswordResetToken(user); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java index 50f593f..2539923 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/CommandDispatcher.java @@ -12,7 +12,7 @@ import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.DeviceCommand; import com.ibrasoft.lensbridge.model.board.DeviceCommandStatus; -import com.ibrasoft.lensbridge.model.board.commands.CommandPayload; +import com.ibrasoft.lensbridge.model.board.commands.CommandKind; import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher; @@ -27,7 +27,6 @@ import java.time.Instant; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.UUID; /** @@ -58,16 +57,6 @@ public class CommandDispatcher { */ private static final Duration DEFAULT_TTL = Duration.ofMinutes(5); - /** Discriminator strings registered on {@link CommandPayload}. Updated when adding a command kind. */ - private static final Set KNOWN_KINDS = Set.of( - "chrome.reload", - "chrome.screenshot", - "kiosk.restart", - "system.reboot", - "config.refresh", - "logs.tail" - ); - private final DeviceCommandRepository commandRepository; private final DeviceRepository deviceRepository; private final AgentSessionRegistry registry; @@ -76,9 +65,8 @@ public class CommandDispatcher { @Transactional public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandRequest request) { - if (!KNOWN_KINDS.contains(request.kind())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown command kind: " + request.kind()); - } + CommandKind kind = CommandKind.from(request.kind()).orElseThrow(() -> + new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown command kind: " + request.kind())); Device device = deviceRepository.findById(deviceId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Device not found")); if (device.getRevokedAt() != null) { @@ -112,8 +100,8 @@ public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandR registry.get(deviceId).ifPresent(session -> deliverTo(cmd, session)); - log.info("Issued command {} ({}) for device {} by {} — initial status {}", - cmd.getId(), cmd.getKind(), deviceId, issuedBy, cmd.getStatus()); + log.info("Issued command {} ({}, risk={}) for device {} by {} — initial status {}", + cmd.getId(), cmd.getKind(), kind.getRisk(), deviceId, issuedBy, cmd.getStatus()); return new CommandIssuedResponse( cmd.getId(), cmd.getDeviceId(), cmd.getKind(), cmd.getStatus(), diff --git a/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java b/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java new file mode 100644 index 0000000..ebef770 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/config/stomp/StompJwtChannelInterceptorTest.java @@ -0,0 +1,191 @@ +package com.ibrasoft.lensbridge.config.stomp; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.security.jwt.JwtUtils; +import com.ibrasoft.lensbridge.security.services.UserDetailsServiceImpl; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The device topics carry command output, which for chrome.screenshot is an image of a + * display in a prayer space. A valid JWT alone must not be enough to read them. + */ +class StompJwtChannelInterceptorTest { + + private final JwtUtils jwtUtils = mock(JwtUtils.class); + private final UserDetailsServiceImpl userDetailsService = mock(UserDetailsServiceImpl.class); + private final StompJwtChannelInterceptor interceptor = + new StompJwtChannelInterceptor(jwtUtils, userDetailsService); + private final MessageChannel channel = mock(MessageChannel.class); + + private static Message subscribeTo(String destination, Permission... permissions) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setDestination(destination); + List authorities = java.util.Arrays.stream(permissions) + .map(p -> (GrantedAuthority) new SimpleGrantedAuthority(p.getAuthority())) + .toList(); + accessor.setUser(new UsernamePasswordAuthenticationToken("u@example.com", null, authorities)); + accessor.setLeaveMutable(true); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } + + private static Message subscribeAnonymously(String destination) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setDestination(destination); + accessor.setLeaveMutable(true); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } + + @Test + void subscribeToDeviceTopicRequiresTelemetryPermission() { + Message message = subscribeTo("/topic/devices/abc/commands"); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining(Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + } + + /** The regression this check exists for: a plain uploader must not read command output. */ + @Test + void plainUserCannotSubscribeToCommandOutput() { + Message message = subscribeTo("/topic/devices/abc/commands", + Role.USER.getPermissions().toArray(new Permission[0])); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void subscribeIsAllowedWithTelemetryPermission() { + Message message = subscribeTo("/topic/devices/abc/commands", + Permission.BOARD_TELEMETRY_SUBSCRIBE); + + assertThat(interceptor.preSend(message, channel)).isSameAs(message); + } + + @Test + void boardViewerCanSubscribe() { + Message message = subscribeTo("/topic/devices", + Role.BOARD_VIEWER.getPermissions().toArray(new Permission[0])); + + assertThat(interceptor.preSend(message, channel)).isSameAs(message); + } + + @Test + void subscribeWithoutAPrincipalIsRejected() { + Message message = subscribeAnonymously("/topic/devices/abc"); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("Not authenticated"); + } + + @Test + void subscribeToAnUnrelatedLiteralDestinationIsNotGated() { + Message message = subscribeAnonymously("/topic/something-else"); + + assertThat(interceptor.preSend(message, channel)).isSameAs(message); + } + + /** + * The SimpleBroker matches a subscription destination against published destinations + * with an AntPathMatcher, so a subscription is a pattern. A prefix test on the + * raw string is therefore not a gate: /topic/** does not start with /topic/devices but + * still receives every message published there, screenshots included. + */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = { + "/topic/**", + "/topic/*/**", + "/topic/*", + "/topic/devices*", + "/topic/device?/**", + "/topic/{anything}/commands", + "/**", + }) + void wildcardSubscriptionsCannotBeUsedToReachDeviceTopics(String destination) { + Message message = subscribeTo(destination); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class); + } + + /** Even holding the permission, a pattern subscription is refused rather than authorized. */ + @Test + void wildcardSubscriptionsAreRefusedEvenWithTelemetryPermission() { + Message message = subscribeTo("/topic/**", Permission.BOARD_TELEMETRY_SUBSCRIBE); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("Pattern subscriptions are not permitted"); + } + + // ==================== CONNECT, unchanged behaviour ==================== + + @Test + void connectWithoutAuthorizationHeaderIsRejected() { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.CONNECT); + accessor.setLeaveMutable(true); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Missing Authorization header"); + } + + @Test + void connectWithInvalidTokenIsRejected() { + when(jwtUtils.validateJwtToken("bad")).thenReturn(false); + + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.CONNECT); + accessor.setNativeHeader("Authorization", "Bearer bad"); + accessor.setLeaveMutable(true); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid JWT"); + } + + @Test + void connectWithValidTokenAttachesThePrincipal() { + UserDetails user = User.withUsername("u@example.com") + .password("x") + .authorities(new SimpleGrantedAuthority( + Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority())) + .build(); + when(jwtUtils.validateJwtToken("good")).thenReturn(true); + when(jwtUtils.getUserNameFromJwtToken("good")).thenReturn("u@example.com"); + when(userDetailsService.loadUserByUsername("u@example.com")).thenReturn(user); + + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.CONNECT); + accessor.setNativeHeader("Authorization", "Bearer good"); + accessor.setLeaveMutable(true); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + interceptor.preSend(message, channel); + + StompHeaderAccessor result = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + assertThat(result).isNotNull(); + assertThat(result.getUser()).isNotNull(); + assertThat(result.getUser().getName()).isEqualTo("u@example.com"); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java index 32511a1..ac5ce89 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.controller; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.EventsService; @@ -8,58 +9,150 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +/** + * {@link AdminController} carries a mixed authorization model, deliberately: media + * moderation stays on the legacy {@code hasRole('ADMIN')} class-level annotation, while the + * identity and audit endpoints moved to permissions. This pins both halves — in particular + * that the board roles gained no media access on the way through. + */ @WebMvcTest(controllers = AdminController.class) +@Import(MethodSecurityTestConfig.class) class AdminControllerIntegrationTest { + private static final String BASE = "/api/admin"; + @Autowired private MockMvc mockMvc; @MockitoBean private UploadService uploadService; - @MockitoBean private EventsService eventsService; - @MockitoBean private AdminAuditService adminAuditService; - @MockitoBean private UserService userService; + private void expectForbidden(MockHttpServletRequestBuilder request, Role role) throws Exception { + mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(status().isForbidden()); + } + + private void expectAllowed(MockHttpServletRequestBuilder request, Role role) throws Exception { + mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(result -> { + int s = result.getResponse().getStatus(); + if (s == 401 || s == 403) { + throw new AssertionError("Expected " + role + " to be authorized but got HTTP " + s); + } + }); + } + + @Test + void anonymousIsUnauthorized() throws Exception { + mockMvc.perform(get(BASE + "/uploads")).andExpect(status().isUnauthorized()); + mockMvc.perform(get(BASE + "/users")).andExpect(status().isUnauthorized()); + } + + // ==================== Legacy media: still role-gated, unchanged ==================== + + @Test + void mediaModerationStillRequiresTheAdminRole() throws Exception { + expectAllowed(get(BASE + "/uploads"), Role.ADMIN); + expectAllowed(get(BASE + "/uploads/pending"), Role.ADMIN); + expectForbidden(get(BASE + "/uploads"), Role.USER); + } + + /** + * ROOT is defined as holding every permission, so it must not be refused the one place + * still gated by a role name. Before the class-level gate also accepted the permission, + * a ROOT-only account got 403 here and needed ROLE_ADMIN granted separately. + */ + @Test + void rootReachesMediaWithoutAlsoHoldingTheAdminRole() throws Exception { + expectAllowed(get(BASE + "/uploads"), Role.ROOT); + expectAllowed(get(BASE + "/uploads/pending"), Role.ROOT); + expectAllowed(post(BASE + "/feature-upload/" + java.util.UUID.randomUUID()), Role.ROOT); + } + + /** The permission alone opens the gate — no role authority present at all. */ + @Test + void theModeratePermissionAloneIsSufficient() throws Exception { + mockMvc.perform(get(BASE + "/uploads").with( + TestAuthorities.asHolderOf(Permission.MEDIA_UPLOAD_MODERATE))) + .andExpect(status().isOk()); + } + + /** Widening the gate must not have widened it to anyone else. */ + @Test + void noOtherPermissionOpensTheMediaGate() throws Exception { + mockMvc.perform(get(BASE + "/uploads").with( + TestAuthorities.asHolderOf(Permission.MEDIA_UPLOAD_READ))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(BASE + "/uploads").with( + TestAuthorities.asHolderOf(Permission.BOARD_CONTENT_READ))) + .andExpect(status().isForbidden()); + } + + /** The point of keeping media on roles: board roles must not have drifted into it. */ + @Test + void boardRolesHaveNoMediaAccess() throws Exception { + expectForbidden(get(BASE + "/uploads"), Role.BOARD_ADMIN); + expectForbidden(get(BASE + "/uploads"), Role.BOARD_EDITOR); + // A mutating media endpoint, not just a read. Chosen for taking no query params: + // Spring resolves handler arguments before method security runs, so an endpoint with + // required params answers 500 for a missing param before authorization is consulted. + expectForbidden(post(BASE + "/feature-upload/" + java.util.UUID.randomUUID()), + Role.BOARD_ADMIN); + } + + // ==================== Identity: permission-gated ==================== + + @Test + void listingUsersRequiresIamUserRead() throws Exception { + mockMvc.perform(get(BASE + "/users").with( + TestAuthorities.asHolderOf(Permission.IAM_USER_READ))) + .andExpect(status().isOk()); + } + + /** + * ADMIN moderates media; it does not read the user directory. Method-level + * {@code @PreAuthorize} replaces the class-level annotation rather than combining with + * it, so this is the whole check for these endpoints. + */ + @Test + void mediaAdminCannotListUsersOrGrantRoles() throws Exception { + expectForbidden(get(BASE + "/users"), Role.ADMIN); + expectForbidden(get(BASE + "/roles"), Role.ADMIN); + } + + @Test + void onlyRootCanGrantRoles() throws Exception { + expectAllowed(get(BASE + "/roles"), Role.ROOT); + expectAllowed(get(BASE + "/permissions"), Role.ROOT); + expectForbidden(get(BASE + "/users"), Role.BOARD_ADMIN); + } + + // ==================== Audit: permission-gated ==================== + + @Test + void auditIsReadableByAnyoneHoldingAuditRead() throws Exception { + expectAllowed(get(BASE + "/audit"), Role.ADMIN); + // BOARD_ADMIN needs the audit log for device actions without gaining media moderation. + expectAllowed(get(BASE + "/audit"), Role.BOARD_ADMIN); + expectAllowed(get(BASE + "/audit/actions"), Role.BOARD_ADMIN); + } - private String adminBaseURL = "/api/admin"; - - // @Test - // void accessDeniedForUnauthenticatedUsers() throws Exception { - // mockMvc.perform(get(adminBaseURL + "/create-event")) - // .andExpect(status().isUnauthorized()); - // } - - // @Test - // @WithMockUser(roles = {Role.Authority.USER}) - // void accessDeniedForUserRole() throws Exception { - // mockMvc.perform(post(adminBaseURL + "/create-event").with(csrf())) - // .andExpect(status().isForbidden()); - // } - - // @Test - // @WithMockUser(username = "admin@mail.utoronto.ca", - // authorities = {"ROLE_ADMIN"}) - // void accessGrantedForAdminRole() throws Exception { - // mockMvc.perform(post(adminBaseURL + "/create-event") - // .with(csrf()) - // .param("eventName", "Test Event") - // .param("eventDate", "2024-12-01T10:00:00") - // .param("status", "UPCOMING")) - // .andExpect(status().isOk()); - // } + @Test + void auditIsNotReadableWithoutAuditRead() throws Exception { + expectForbidden(get(BASE + "/audit"), Role.BOARD_EDITOR); + expectForbidden(get(BASE + "/audit"), Role.USER); + } } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java index 1f1fcb4..972eb19 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerResetPasswordTest.java @@ -45,7 +45,8 @@ void setUp() { userService, mock(JwtUtils.class), mock(LoginAttemptService.class), - mock(RefreshTokenService.class)); + mock(RefreshTokenService.class), + mock(com.ibrasoft.lensbridge.security.services.AuthorityResolver.class)); LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); validator.afterPropertiesSet(); diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java index a6fb282..07d6b89 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AuthControllerValidateResetTokenTest.java @@ -47,7 +47,8 @@ void setUp() { userService, mock(JwtUtils.class), mock(LoginAttemptService.class), - mock(RefreshTokenService.class)); + mock(RefreshTokenService.class), + mock(com.ibrasoft.lensbridge.security.services.AuthorityResolver.class)); LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); validator.afterPropertiesSet(); diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java index f971b23..53c9a95 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java @@ -1,160 +1,162 @@ -// package com.ibrasoft.lensbridge.controller; - -// import com.ibrasoft.lensbridge.handler.SignboardHandler; -// import com.ibrasoft.lensbridge.service.AdminAuditService; -// import com.ibrasoft.lensbridge.service.BoardService; -// import com.ibrasoft.lensbridge.service.PosterService; -// import org.junit.jupiter.api.Test; -// import org.junit.jupiter.params.ParameterizedTest; -// import org.junit.jupiter.params.provider.CsvSource; -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -// import org.springframework.context.annotation.Import; -// import org.springframework.http.MediaType; -// import org.springframework.security.test.context.support.WithAnonymousUser; -// import org.springframework.security.test.context.support.WithMockUser; -// import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; -// import org.springframework.test.context.bean.override.mockito.MockitoBean; -// import org.springframework.test.web.servlet.MockMvc; -// import org.springframework.test.web.servlet.ResultActions; -// import org.springframework.test.web.servlet.request.RequestPostProcessor; - -// import java.util.UUID; - -// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -// import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -// /** -// * Security-only integration tests for {@link BoardAdminController}. Every endpoint is class-level -// * {@code @PreAuthorize("hasRole('ROOT')")}; this verifies anonymous -> 401, USER/ADMIN -> 403, -// * and ROOT -> not 401/403. CSRF is disabled, so no csrf() post-processor is needed. -// */ -// @WebMvcTest(controllers = BoardAdminController.class) -// @Import(MethodSecurityTestConfig.class) -// class BoardAdminControllerPermissionTest { - -// private static final String BASE = "/api/admin/board"; - -// @Autowired -// private MockMvc mockMvc; - -// @MockitoBean -// private PosterService posterService; -// @MockitoBean -// private BoardService boardService; -// @MockitoBean -// private AdminAuditService auditService; -// @MockitoBean -// private SignboardHandler signboardHandler; - -// private static String id() { -// return UUID.randomUUID().toString(); -// } - -// // ==================== Anonymous -> 401 ==================== - -// @Test -// @WithAnonymousUser -// void anonymousIsUnauthorizedForAllEndpoints() throws Exception { -// mockMvc.perform(get(BASE + "/configs")).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/configs/" + id())).andExpect(status().isUnauthorized()); -// mockMvc.perform(put(BASE + "/configs/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(patch(BASE + "/configs/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/weekly-content")).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/weekly-content/year/2026")).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/weekly-content/2026/1")).andExpect(status().isUnauthorized()); -// mockMvc.perform(put(BASE + "/weekly-content/2026/1").contentType(MediaType.APPLICATION_JSON).content("{}")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(delete(BASE + "/weekly-content/2026/1")).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/posters")).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/posters/by-audience").param("audience", "BOTH")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/posters/" + id())).andExpect(status().isUnauthorized()); -// mockMvc.perform(post(BASE + "/posters").contentType(MediaType.MULTIPART_FORM_DATA)) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(patch(BASE + "/posters/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(put(BASE + "/posters/" + id() + "/image").contentType(MediaType.MULTIPART_FORM_DATA)) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(delete(BASE + "/posters/" + id())).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/events")).andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/events/by-audience").param("audience", "BOTH")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(get(BASE + "/events/" + id())).andExpect(status().isUnauthorized()); -// mockMvc.perform(post(BASE + "/events").contentType(MediaType.APPLICATION_JSON).content("{}")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(patch(BASE + "/events/" + id()).contentType(MediaType.APPLICATION_JSON).content("{}")) -// .andExpect(status().isUnauthorized()); -// mockMvc.perform(delete(BASE + "/events/" + id())).andExpect(status().isUnauthorized()); -// mockMvc.perform(post(BASE + "/refresh")).andExpect(status().isUnauthorized()); -// } - -// // ==================== USER / ADMIN -> 403 ==================== - -// @ParameterizedTest -// @CsvSource({"USER", "ADMIN"}) -// @WithMockUser -// void nonRootRolesAreForbidden(String role) throws Exception { -// runAllEndpoints(role, true); -// } - -// // ==================== ROOT -> not 401/403 ==================== - -// @Test -// @WithMockUser(roles = "ROOT") -// void rootIsAuthorized() throws Exception { -// runAllEndpoints("ROOT", false); -// } - -// private void runAllEndpoints(String role, boolean expectForbidden) throws Exception { -// RequestPostProcessor user = SecurityMockMvcRequestPostProcessors.user("u").roles(role); - -// check(expectForbidden, mockMvc.perform(get(BASE + "/configs").with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/configs/" + id()).with(user))); -// check(expectForbidden, mockMvc.perform(put(BASE + "/configs/" + id()).with(user) -// .contentType(MediaType.APPLICATION_JSON).content("{}"))); -// check(expectForbidden, mockMvc.perform(patch(BASE + "/configs/" + id()).with(user) -// .contentType(MediaType.APPLICATION_JSON).content("{}"))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/weekly-content").with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/weekly-content/year/2026").with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/weekly-content/2026/1").with(user))); -// check(expectForbidden, mockMvc.perform(put(BASE + "/weekly-content/2026/1").with(user) -// .contentType(MediaType.APPLICATION_JSON).content("{}"))); -// check(expectForbidden, mockMvc.perform(delete(BASE + "/weekly-content/2026/1").with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/posters").with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/posters/by-audience").with(user) -// .param("audience", "BOTH"))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/posters/" + id()).with(user))); -// check(expectForbidden, mockMvc.perform(post(BASE + "/posters").with(user) -// .contentType(MediaType.MULTIPART_FORM_DATA))); -// check(expectForbidden, mockMvc.perform(patch(BASE + "/posters/" + id()).with(user) -// .contentType(MediaType.APPLICATION_JSON).content("{}"))); -// check(expectForbidden, mockMvc.perform(put(BASE + "/posters/" + id() + "/image").with(user) -// .contentType(MediaType.MULTIPART_FORM_DATA))); -// check(expectForbidden, mockMvc.perform(delete(BASE + "/posters/" + id()).with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/events").with(user))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/events/by-audience").with(user) -// .param("audience", "BOTH"))); -// check(expectForbidden, mockMvc.perform(get(BASE + "/events/" + id()).with(user))); -// check(expectForbidden, mockMvc.perform(post(BASE + "/events").with(user) -// .contentType(MediaType.APPLICATION_JSON).content("{}"))); -// check(expectForbidden, mockMvc.perform(patch(BASE + "/events/" + id()).with(user) -// .contentType(MediaType.APPLICATION_JSON).content("{}"))); -// check(expectForbidden, mockMvc.perform(delete(BASE + "/events/" + id()).with(user))); -// check(expectForbidden, mockMvc.perform(post(BASE + "/refresh").with(user))); -// } - -// private void check(boolean expectForbidden, ResultActions actions) throws Exception { -// if (expectForbidden) { -// assertForbiddenForRole(actions); -// } else { -// assertNotUnauthorizedOrForbidden(actions); -// } -// } -// } +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.service.AdminAuditService; +import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Security-only tests for {@link BoardAdminController}. Endpoints are authorized by + * permission rather than by role, so what matters here is that a role holding adjacent + * permissions is still refused — a BOARD_EDITOR must not reach board config. + *

+ * CSRF is disabled in production, so no csrf() post-processor is needed. + */ +@WebMvcTest(controllers = BoardAdminController.class) +@Import(MethodSecurityTestConfig.class) +class BoardAdminControllerPermissionTest { + + private static final String BASE = "/api/admin/board"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PosterService posterService; + @MockitoBean + private BoardService boardService; + @MockitoBean + private AdminAuditService auditService; + @MockitoBean + private BoardStreamHandler boardStreamHandler; + + /** + * Not used by this controller. WebConfig registers CurrentUserArgumentResolver, which + * needs a UserService, and the slice does not load the service layer. + */ + @MockitoBean + private UserService userService; + + private static String id() { + return UUID.randomUUID().toString(); + } + + private void expectForbidden(MockHttpServletRequestBuilder request, Role role) throws Exception { + mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(status().isForbidden()); + } + + /** Authorized means "not rejected by security" — the handler's own outcome is out of scope. */ + private void expectAllowed(MockHttpServletRequestBuilder request, Role role) throws Exception { + mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(result -> { + int s = result.getResponse().getStatus(); + if (s == 401 || s == 403) { + throw new AssertionError("Expected " + role + " to be authorized but got HTTP " + s); + } + }); + } + + // ==================== Anonymous ==================== + + @Test + @WithAnonymousUser + void anonymousIsUnauthorized() throws Exception { + mockMvc.perform(get(BASE + "/posters")).andExpect(status().isUnauthorized()); + mockMvc.perform(get(BASE + "/configs")).andExpect(status().isUnauthorized()); + mockMvc.perform(post(BASE + "/refresh")).andExpect(status().isUnauthorized()); + } + + // ==================== Viewer: reads yes, writes no ==================== + + @Test + void viewerCanReadContentAndConfig() throws Exception { + expectAllowed(get(BASE + "/posters"), Role.BOARD_VIEWER); + expectAllowed(get(BASE + "/events"), Role.BOARD_VIEWER); + expectAllowed(get(BASE + "/weekly-content"), Role.BOARD_VIEWER); + expectAllowed(get(BASE + "/configs"), Role.BOARD_VIEWER); + } + + @Test + void viewerCannotWriteAnything() throws Exception { + expectForbidden(delete(BASE + "/posters/" + id()), Role.BOARD_VIEWER); + expectForbidden(delete(BASE + "/events/" + id()), Role.BOARD_VIEWER); + expectForbidden(put(BASE + "/weekly-content/2026/1") + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_VIEWER); + expectForbidden(patch(BASE + "/configs/" + id()) + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_VIEWER); + expectForbidden(post(BASE + "/refresh"), Role.BOARD_VIEWER); + } + + // ==================== Editor: content yes, board config no ==================== + + @Test + void editorCanWriteContent() throws Exception { + expectAllowed(delete(BASE + "/posters/" + id()), Role.BOARD_EDITOR); + expectAllowed(delete(BASE + "/events/" + id()), Role.BOARD_EDITOR); + expectAllowed(put(BASE + "/weekly-content/2026/1") + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_EDITOR); + expectAllowed(post(BASE + "/refresh"), Role.BOARD_EDITOR); + } + + /** The separation this redesign exists for: editing copy is not administering hardware. */ + @Test + void editorCannotTouchBoardConfig() throws Exception { + expectForbidden(patch(BASE + "/configs/" + id()) + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_EDITOR); + expectForbidden(put(BASE + "/configs/" + id()) + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_EDITOR); + } + + /** But the ticker is copy, so it has its own door. */ + @Test + void editorCanWriteTheTickerWithoutConfigWrite() throws Exception { + expectAllowed(patch(BASE + "/configs/" + id() + "/ticker") + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_EDITOR); + } + + // ==================== Media roles have no board access ==================== + + @Test + void mediaAdminHasNoBoardAccess() throws Exception { + expectForbidden(get(BASE + "/posters"), Role.ADMIN); + expectForbidden(get(BASE + "/configs"), Role.ADMIN); + expectForbidden(delete(BASE + "/posters/" + id()), Role.ADMIN); + } + + @Test + void plainUserHasNoBoardAccess() throws Exception { + expectForbidden(get(BASE + "/posters"), Role.USER); + expectForbidden(get(BASE + "/configs"), Role.USER); + } + + // ==================== Root ==================== + + @Test + void rootIsAuthorizedEverywhere() throws Exception { + expectAllowed(get(BASE + "/posters"), Role.ROOT); + expectAllowed(get(BASE + "/configs"), Role.ROOT); + expectAllowed(patch(BASE + "/configs/" + id()) + .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.ROOT); + expectAllowed(delete(BASE + "/posters/" + id()), Role.ROOT); + expectAllowed(post(BASE + "/refresh"), Role.ROOT); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java index 75630da..892d87d 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerTest.java @@ -1,8 +1,10 @@ package com.ibrasoft.lensbridge.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; import com.ibrasoft.lensbridge.service.agent.AgentSession; @@ -13,10 +15,12 @@ import org.junit.jupiter.api.Test; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketSession; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -39,6 +43,7 @@ void revoke_closesRegisteredAgentSession() throws Exception { DeviceCommandRepository commandRepository = mock(DeviceCommandRepository.class); CommandDispatcher commandDispatcher = mock(CommandDispatcher.class); AgentSessionRegistry registry = new AgentSessionRegistry(); + AdminAuditService auditService = mock(AdminAuditService.class); DeviceAdminController controller = new DeviceAdminController( enrollmentTokenService, @@ -46,7 +51,8 @@ void revoke_closesRegisteredAgentSession() throws Exception { commandRepository, commandDispatcher, new ObjectMapper(), - registry); + registry, + auditService); UUID deviceId = UUID.randomUUID(); Device device = Device.builder() @@ -65,13 +71,51 @@ void revoke_closesRegisteredAgentSession() throws Exception { setCurrentUser(); - controller.revoke(deviceId); + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + httpRequest.setRemoteAddr("10.0.0.1"); + + controller.revoke(deviceId, httpRequest); assertNotNull(device.getRevokedAt()); assertTrue(registry.get(deviceId).isEmpty()); verify(transport).close(argThat(status -> status.getCode() == CloseStatus.POLICY_VIOLATION.getCode() && "device_revoked".equals(status.getReason()))); + verify(auditService).logAuditEvent( + eq("root@example.com"), eq(AuditAction.REVOKE_DEVICE), + eq("Device"), eq(deviceId), eq("10.0.0.1")); + } + + @Test + void revoke_isANoOpAndDoesNotReAuditWhenAlreadyRevoked() throws Exception { + DeviceRepository deviceRepository = mock(DeviceRepository.class); + AdminAuditService auditService = mock(AdminAuditService.class); + + DeviceAdminController controller = new DeviceAdminController( + mock(EnrollmentTokenService.class), + deviceRepository, + mock(DeviceCommandRepository.class), + mock(CommandDispatcher.class), + new ObjectMapper(), + new AgentSessionRegistry(), + auditService); + + UUID deviceId = UUID.randomUUID(); + Instant revokedAt = Instant.now().minusSeconds(3600); + Device device = Device.builder() + .id(deviceId) + .displayName("Lobby board") + .audience(Audience.BOTH) + .revokedAt(revokedAt) + .build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + + setCurrentUser(); + controller.revoke(deviceId, new MockHttpServletRequest()); + + assertEquals(revokedAt, device.getRevokedAt()); + verify(deviceRepository, never()).save(any()); + verifyNoInteractions(auditService); } private static void setCurrentUser() { diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/TestAuthorities.java b/src/test/java/com/ibrasoft/lensbridge/controller/TestAuthorities.java new file mode 100644 index 0000000..fc8d6c4 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/TestAuthorities.java @@ -0,0 +1,56 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Builds the authority set a real request would carry, so permission tests exercise the + * production shape rather than a convenient approximation. + *

+ * Mirrors {@code AuthorityResolver}: a role contributes both its {@code ROLE_*} authority + * and every permission it confers. {@code @WithMockUser(roles = "ADMIN")} grants only the + * former, which would make any {@code hasAuthority} check look broken. + */ +final class TestAuthorities { + + private TestAuthorities() { + } + + /** Authenticates as a principal holding these roles, fully expanded. */ + static RequestPostProcessor as(Role... roles) { + return SecurityMockMvcRequestPostProcessors + .user("u@example.com") + .authorities(expand(roles)); + } + + /** Authenticates as a principal holding exactly these permissions and no role at all. */ + static RequestPostProcessor asHolderOf(Permission... permissions) { + List authorities = new ArrayList<>(); + for (Permission permission : permissions) { + authorities.add(new SimpleGrantedAuthority(permission.getAuthority())); + } + return SecurityMockMvcRequestPostProcessors + .user("u@example.com") + .authorities(authorities.toArray(new GrantedAuthority[0])); + } + + private static GrantedAuthority[] expand(Role... roles) { + Set authorities = new LinkedHashSet<>(); + for (Role role : roles) { + authorities.add(new SimpleGrantedAuthority(role.getAuthority())); + for (Permission permission : role.getPermissions()) { + authorities.add(new SimpleGrantedAuthority(permission.getAuthority())); + } + } + return authorities.toArray(new GrantedAuthority[0]); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/auth/PermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/model/auth/PermissionTest.java new file mode 100644 index 0000000..5be4f31 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/auth/PermissionTest.java @@ -0,0 +1,54 @@ +package com.ibrasoft.lensbridge.model.auth; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class PermissionTest { + + @ParameterizedTest + @EnumSource(Permission.class) + void authorityIsNamespacedAndCarriesNoRolePrefix(Permission permission) { + // hasAuthority() matches literally. A ROLE_ prefix here would make the permission + // indistinguishable from a role authority in the same collection. + assertThat(permission.getAuthority()) + .doesNotStartWith("ROLE_") + .matches("[a-z]+(:[a-z]+)+"); + } + + @Test + void authoritiesAreUnique() { + Set seen = new HashSet<>(); + for (Permission permission : Permission.values()) { + assertThat(seen.add(permission.getAuthority())) + .as("duplicate authority string: %s", permission.getAuthority()) + .isTrue(); + } + } + + /** + * The {@code Authority} constants are what {@code @PreAuthorize} sites reference. If one + * existed that no enum constant used, an annotation could silently check a permission + * nobody can ever hold — a permanent 403 that looks like a config problem. + */ + @Test + void everyAuthorityConstantBacksAnEnumConstant() throws IllegalAccessException { + Set declared = new HashSet<>(); + for (Field field : Permission.Authority.class.getDeclaredFields()) { + declared.add((String) field.get(null)); + } + + Set used = Arrays.stream(Permission.values()) + .map(Permission::getAuthority) + .collect(java.util.stream.Collectors.toSet()); + + assertThat(declared).isEqualTo(used); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/auth/RolePermissionsTest.java b/src/test/java/com/ibrasoft/lensbridge/model/auth/RolePermissionsTest.java new file mode 100644 index 0000000..4d02478 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/auth/RolePermissionsTest.java @@ -0,0 +1,123 @@ +package com.ibrasoft.lensbridge.model.auth; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.EnumSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The authorization policy, asserted. Every test here is a property the design depends on; + * if one fails, someone widened a bundle and should have to say so out loud. + */ +class RolePermissionsTest { + + @ParameterizedTest + @EnumSource(Role.class) + void everyRoleHasABundle(Role role) { + assertThat(role.getPermissions()).isNotNull(); + } + + @ParameterizedTest + @EnumSource(Role.class) + void bundlesAreImmutable(Role role) { + assertThatThrownBy(() -> role.getPermissions().add(Permission.IAM_ROLE_GRANT)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rootHoldsEverything() { + assertThat(Role.ROOT.getPermissions()) + .containsExactlyInAnyOrderElementsOf(EnumSet.allOf(Permission.class)); + } + + @Test + void onlyRootCanGrantRoles() { + for (Role role : Role.values()) { + if (role == Role.ROOT) continue; + assertThat(role.getPermissions()) + .as("%s must not confer the escalation permission", role) + .doesNotContain(Permission.IAM_ROLE_GRANT); + } + } + + // ==================== Legacy media, unchanged ==================== + + @Test + void adminContainsUser() { + assertThat(Role.ADMIN.getPermissions()).containsAll(Role.USER.getPermissions()); + } + + @Test + void userCanOnlyManageItsOwnUploads() { + assertThat(Role.USER.getPermissions()).containsExactly(Permission.MEDIA_UPLOAD_SELF); + } + + @Test + void mediaModerationStaysOutOfEveryBoardRole() { + for (Role role : new Role[]{Role.BOARD_VIEWER, Role.BOARD_EDITOR, + Role.BOARD_ADMIN}) { + assertThat(role.getPermissions()) + .as("%s must not confer media moderation", role) + .doesNotContain(Permission.MEDIA_UPLOAD_MODERATE); + } + } + + // ==================== MusallahBoard ==================== + + @Test + void editorAndOperatorBothContainViewer() { + assertThat(Role.BOARD_EDITOR.getPermissions()).containsAll(Role.BOARD_VIEWER.getPermissions()); + } + + @Test + void viewerCanChangeNothing() { + assertThat(Role.BOARD_VIEWER.getPermissions()) + .allSatisfy(p -> assertThat(p.getAuthority()) + .matches(".*(:read|:subscribe)$")); + } + + @Test + void editorCanChangeContentButNotTheBoardItself() { + assertThat(Role.BOARD_EDITOR.getPermissions()) + .contains(Permission.BOARD_POSTER_WRITE, + Permission.BOARD_EVENT_WRITE, + Permission.BOARD_WEEKLY_WRITE, + Permission.BOARD_TICKER_WRITE) + .doesNotContain(Permission.BOARD_CONFIG_WRITE, + Permission.BOARD_COMMAND_BENIGN, + Permission.BOARD_COMMAND_DISRUPTIVE, + Permission.BOARD_COMMAND_INSPECT, + Permission.BOARD_DEVICE_ENROLL, + Permission.BOARD_DEVICE_REVOKE); + } + + /** The whole point of splitting the ticker out of the config. */ + @Test + void editorCanWriteTickerCopyWithoutConfigWrite() { + assertThat(Role.BOARD_EDITOR.getPermissions()) + .contains(Permission.BOARD_TICKER_WRITE) + .doesNotContain(Permission.BOARD_CONFIG_WRITE); + } + + @Test + void boardAdminSubsumesEditorAndOperator() { + assertThat(Role.BOARD_ADMIN.getPermissions()) + .containsAll(Role.BOARD_EDITOR.getPermissions()) + .contains(Permission.BOARD_DEVICE_ENROLL, + Permission.BOARD_DEVICE_REVOKE, + Permission.BOARD_COMMAND_INSPECT); + } + + /** Owning the boards is not owning the org's accounts. */ + @Test + void boardAdminHoldsNoIdentityPermissions() { + assertThat(Role.BOARD_ADMIN.getPermissions()) + .doesNotContain(Permission.IAM_USER_READ, + Permission.IAM_USER_WRITE, + Permission.IAM_ROLE_GRANT); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/commands/CommandKindTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/commands/CommandKindTest.java new file mode 100644 index 0000000..4283b56 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/commands/CommandKindTest.java @@ -0,0 +1,97 @@ +package com.ibrasoft.lensbridge.model.board.commands; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.ibrasoft.lensbridge.model.auth.Permission; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandKindTest { + + /** + * The registry and the Jackson subtypes must describe the same set of commands. A kind + * registered with Jackson but missing here would be unclassifiable and therefore + * unauthorizable; a kind here but not with Jackson could never be deserialized. + */ + @Test + void wireNamesMatchTheJacksonSubtypeRegistration() { + JsonSubTypes subTypes = CommandPayload.class.getAnnotation(JsonSubTypes.class); + assertThat(subTypes).as("CommandPayload must remain @JsonSubTypes-annotated").isNotNull(); + + Set jackson = Arrays.stream(subTypes.value()) + .map(JsonSubTypes.Type::name) + .collect(Collectors.toSet()); + Set registry = Arrays.stream(CommandKind.values()) + .map(CommandKind::getWireName) + .collect(Collectors.toSet()); + + assertThat(registry).isEqualTo(jackson); + } + + @ParameterizedTest + @EnumSource(CommandKind.class) + void everyKindRoundTripsThroughItsWireName(CommandKind kind) { + assertThat(CommandKind.from(kind.getWireName())).contains(kind); + } + + @Test + void unknownAndNullKindsResolveToEmpty() { + assertThat(CommandKind.from("chrome.explode")).isEmpty(); + assertThat(CommandKind.from(null)).isEmpty(); + assertThat(CommandKind.from("")).isEmpty(); + } + + // ==================== Risk classification ==================== + // + // These assertions are the security boundary, not documentation. Reclassifying a command + // silently changes who can run it, so each mapping is pinned individually. + + @Test + void reloadAndConfigRefreshAreBenign() { + assertThat(CommandKind.CHROME_RELOAD.getRisk()).isEqualTo(CommandRisk.BENIGN); + assertThat(CommandKind.CONFIG_REFRESH.getRisk()).isEqualTo(CommandRisk.BENIGN); + } + + @Test + void restartAndRebootAreDisruptive() { + assertThat(CommandKind.KIOSK_RESTART.getRisk()).isEqualTo(CommandRisk.DISRUPTIVE); + assertThat(CommandKind.SYSTEM_REBOOT.getRisk()).isEqualTo(CommandRisk.DISRUPTIVE); + } + + /** Both return device-side data to the caller. Neither may ride along with a reboot grant. */ + @Test + void screenshotAndLogTailAreInspect() { + assertThat(CommandKind.CHROME_SCREENSHOT.getRisk()).isEqualTo(CommandRisk.INSPECT); + assertThat(CommandKind.LOGS_TAIL.getRisk()).isEqualTo(CommandRisk.INSPECT); + } + + @Test + void eachRiskClassMapsToItsOwnPermission() { + assertThat(CommandRisk.BENIGN.getRequiredPermission()) + .isEqualTo(Permission.BOARD_COMMAND_BENIGN); + assertThat(CommandRisk.DISRUPTIVE.getRequiredPermission()) + .isEqualTo(Permission.BOARD_COMMAND_DISRUPTIVE); + assertThat(CommandRisk.INSPECT.getRequiredPermission()) + .isEqualTo(Permission.BOARD_COMMAND_INSPECT); + + Set distinct = Arrays.stream(CommandRisk.values()) + .map(CommandRisk::getRequiredPermission) + .collect(Collectors.toSet()); + assertThat(distinct).hasSameSizeAs(CommandRisk.values()); + } + + @Test + void screenshotRequiresInspectRatherThanDisruptive() { + Optional screenshot = CommandKind.from("chrome.screenshot"); + assertThat(screenshot).isPresent(); + assertThat(screenshot.get().getRequiredPermission()) + .isEqualTo(Permission.BOARD_COMMAND_INSPECT); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/security/CommandAuthorizerTest.java b/src/test/java/com/ibrasoft/lensbridge/security/CommandAuthorizerTest.java new file mode 100644 index 0000000..5463d65 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/security/CommandAuthorizerTest.java @@ -0,0 +1,113 @@ +package com.ibrasoft.lensbridge.security; + +import com.ibrasoft.lensbridge.dto.board.request.IssueCommandRequest; +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandAuthorizerTest { + + private final CommandAuthorizer authorizer = new CommandAuthorizer(); + + private static Authentication withPermissions(Permission... permissions) { + List authorities = java.util.Arrays.stream(permissions) + .map(p -> (GrantedAuthority) new SimpleGrantedAuthority(p.getAuthority())) + .toList(); + return new UsernamePasswordAuthenticationToken("u@example.com", null, authorities); + } + + private static Authentication withRole(Role role) { + List authorities = role.getPermissions().stream() + .map(p -> (GrantedAuthority) new SimpleGrantedAuthority(p.getAuthority())) + .toList(); + return new UsernamePasswordAuthenticationToken("u@example.com", null, authorities); + } + + private static IssueCommandRequest command(String kind) { + return new IssueCommandRequest(kind, null, null, null); + } + + @Test + void benignPermissionAllowsReload() { + assertThat(authorizer.canIssue(withPermissions(Permission.BOARD_COMMAND_BENIGN), + command("chrome.reload"))).isTrue(); + } + + @Test + void benignPermissionDoesNotAllowReboot() { + assertThat(authorizer.canIssue(withPermissions(Permission.BOARD_COMMAND_BENIGN), + command("system.reboot"))).isFalse(); + } + + @Test + void disruptivePermissionDoesNotAllowScreenshot() { + assertThat(authorizer.canIssue(withPermissions(Permission.BOARD_COMMAND_DISRUPTIVE), + command("chrome.screenshot"))).isFalse(); + } + + @Test + void inspectPermissionAllowsScreenshotAndLogTail() { + Authentication auth = withPermissions(Permission.BOARD_COMMAND_INSPECT); + assertThat(authorizer.canIssue(auth, command("chrome.screenshot"))).isTrue(); + assertThat(authorizer.canIssue(auth, command("logs.tail"))).isTrue(); + } + + @Test + void boardEditorMayIssueNoCommandsAtAll() { + Authentication editor = withRole(Role.BOARD_EDITOR); + for (String kind : List.of("chrome.reload", "config.refresh", "kiosk.restart", + "system.reboot", "chrome.screenshot", "logs.tail")) { + assertThat(authorizer.canIssue(editor, command(kind))) + .as("editor must not be able to issue %s", kind) + .isFalse(); + } + } + + @Test + void boardAdminMayIssueEverything() { + Authentication boardAdmin = withRole(Role.BOARD_ADMIN); + for (String kind : List.of("chrome.reload", "config.refresh", "kiosk.restart", + "system.reboot", "chrome.screenshot", "logs.tail")) { + assertThat(authorizer.canIssue(boardAdmin, command(kind))) + .as("board admin must be able to issue %s", kind) + .isTrue(); + } + } + + /** + * An unrecognised kind is passed through so CommandDispatcher can answer 400. A typo + * deserves "no such command", not "you are not allowed" — and nothing is issued either way. + */ + @Test + void unknownKindIsLeftForTheDispatcherToRejectAsBadRequest() { + assertThat(authorizer.canIssue(withPermissions(), command("chrome.explode"))).isTrue(); + } + + @Test + void noAuthenticationIsDenied() { + assertThat(authorizer.canIssue(null, command("chrome.reload"))).isFalse(); + } + + @Test + void nullRequestIsDenied() { + assertThat(authorizer.canIssue(withPermissions(Permission.BOARD_COMMAND_BENIGN), null)) + .isFalse(); + } + + @Test + void unauthenticatedTokenIsDenied() { + Authentication anonymous = new AnonymousAuthenticationToken( + "key", "anonymous", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + anonymous.setAuthenticated(false); + assertThat(authorizer.canIssue(anonymous, command("chrome.reload"))).isFalse(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/security/services/AuthorityResolverTest.java b/src/test/java/com/ibrasoft/lensbridge/security/services/AuthorityResolverTest.java new file mode 100644 index 0000000..2e1286f --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/security/services/AuthorityResolverTest.java @@ -0,0 +1,106 @@ +package com.ibrasoft.lensbridge.security.services; + +import com.ibrasoft.lensbridge.model.auth.Permission; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.auth.User; +import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AuthorityResolverTest { + + private UserRepository userRepository; + private AuthorityResolver resolver; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + resolver = new AuthorityResolver(userRepository); + when(userRepository.findDirectPermissions(any())).thenReturn(Set.of()); + } + + private User user(Role... roles) { + User user = new User("A", "B", "1", "a@b.ca", "p"); + user.setId(UUID.randomUUID()); + for (Role role : roles) { + user.addRole(role); + } + return user; + } + + private Set authorityStrings(User user) { + return resolver.resolveAuthorities(user).stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + } + + @Test + void rolesAreExpandedIntoTheirPermissions() { + assertThat(resolver.resolvePermissions(user(Role.BOARD_EDITOR))) + .containsExactlyInAnyOrderElementsOf(Role.BOARD_EDITOR.getPermissions()); + } + + /** + * Both forms are emitted so the untouched legacy media endpoints, which still say + * hasRole('ADMIN'), keep working while board endpoints check permissions. + */ + @Test + void authoritiesCarryBothTheRoleAndItsPermissions() { + Set authorities = authorityStrings(user(Role.ADMIN)); + + assertThat(authorities).contains("ROLE_ADMIN"); + assertThat(authorities).contains(Permission.MEDIA_UPLOAD_MODERATE.getAuthority()); + } + + @Test + void directGrantsAddToWhatRolesConfer() { + User user = user(Role.BOARD_EDITOR); + when(userRepository.findDirectPermissions(user.getId())) + .thenReturn(Set.of(Permission.BOARD_COMMAND_INSPECT)); + + Set resolved = resolver.resolvePermissions(user); + + assertThat(resolved) + .containsAll(Role.BOARD_EDITOR.getPermissions()) + .contains(Permission.BOARD_COMMAND_INSPECT); + } + + @Test + void aUserWithNoRolesGetsNothing() { + assertThat(resolver.resolvePermissions(user())).isEmpty(); + } + + /** Unsaved entities appear in tests and in the signup path; they must not blow up. */ + @Test + void aUserWithNoIdResolvesWithoutHittingTheRepository() { + User unsaved = new User("A", "B", "1", "a@b.ca", "p"); + unsaved.addRole(Role.USER); + + assertThat(resolver.resolvePermissions(unsaved)) + .containsExactly(Permission.MEDIA_UPLOAD_SELF); + } + + @Test + void rootResolvesToEveryPermission() { + assertThat(resolver.resolvePermissions(user(Role.ROOT))) + .containsExactlyInAnyOrder(Permission.values()); + } + + @Test + void resolvingByIdReturnsEmptyForAnUnknownUser() { + UUID unknown = UUID.randomUUID(); + when(userRepository.findById(unknown)).thenReturn(java.util.Optional.empty()); + + assertThat(resolver.resolvePermissions(unknown)).isEmpty(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java index 2aa43d9..ce854b5 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java @@ -3,10 +3,12 @@ import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; import com.ibrasoft.lensbridge.model.auth.VerificationToken; import com.ibrasoft.lensbridge.repository.auth.UserRepository; +import com.ibrasoft.lensbridge.security.services.AuthorityResolver; import com.ibrasoft.lensbridge.security.services.EmailService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,7 +17,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.EnumSet; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -41,6 +46,8 @@ class UserServiceTest { private VerificationTokenService verificationTokenService; @Mock private RefreshTokenService refreshTokenService; + @Mock + private AuthorityResolver authorityResolver; @InjectMocks private UserService userService; @@ -154,52 +161,239 @@ void verifyDirectlyRejectsUnknownUser() { .hasMessageContaining("not found"); } + // ==================== Grants ==================== + + private static final String ROOT_EMAIL = "root@mail.utoronto.ca"; + + /** An acting user holding everything, and another id to keep IAM_ROLE_GRANT alive. */ + private User rootActor() { + User root = new User("Root", "User", "9", ROOT_EMAIL, "p"); + root.setId(UUID.randomUUID()); + root.addRole(Role.ROOT); + lenient().when(userRepository.findByEmail(ROOT_EMAIL)).thenReturn(Optional.of(root)); + lenient().when(authorityResolver.resolvePermissions(root)) + .thenReturn(EnumSet.allOf(Permission.class)); + return root; + } + + private User target(UUID id) { + User user = new User("A", "B", "1", "a@b.ca", "p"); + user.setId(id); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + return user; + } + @Test void addRoleRejectsDuplicateRole() { + rootActor(); UUID id = UUID.randomUUID(); - User user = new User("A", "B", "1", "a@b.ca", "p"); - user.addRole(Role.ADMIN); - when(userRepository.findById(id)).thenReturn(Optional.of(user)); + target(id).addRole(Role.ADMIN); - assertThatThrownBy(() -> userService.addRole(id, Role.ADMIN)) + assertThatThrownBy(() -> userService.addRole(ROOT_EMAIL, id, Role.ADMIN)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("already has role"); } @Test void addRoleAddsNewRole() { + rootActor(); UUID id = UUID.randomUUID(); - User user = new User("A", "B", "1", "a@b.ca", "p"); - when(userRepository.findById(id)).thenReturn(Optional.of(user)); + target(id); - User result = userService.addRole(id, Role.ADMIN); + User result = userService.addRole(ROOT_EMAIL, id, Role.ADMIN); assertThat(result.hasRole(Role.ADMIN)).isTrue(); } @Test void removeRoleRejectsMissingRole() { + rootActor(); UUID id = UUID.randomUUID(); - User user = new User("A", "B", "1", "a@b.ca", "p"); - when(userRepository.findById(id)).thenReturn(Optional.of(user)); + target(id); - assertThatThrownBy(() -> userService.removeRole(id, Role.ADMIN)) + assertThatThrownBy(() -> userService.removeRole(ROOT_EMAIL, id, Role.ADMIN)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("does not have role"); } @Test void removeRoleRemovesExistingRole() { + rootActor(); UUID id = UUID.randomUUID(); - User user = new User("A", "B", "1", "a@b.ca", "p"); - user.addRole(Role.ADMIN); - when(userRepository.findById(id)).thenReturn(Optional.of(user)); + target(id).addRole(Role.ADMIN); - User result = userService.removeRole(id, Role.ADMIN); + User result = userService.removeRole(ROOT_EMAIL, id, Role.ADMIN); assertThat(result.hasRole(Role.ADMIN)).isFalse(); } + @Test + void addRoleRejectsSelfModification() { + User root = rootActor(); + // The target lookup happens first, and when you target yourself it succeeds. + when(userRepository.findById(root.getId())).thenReturn(Optional.of(root)); + + assertThatThrownBy(() -> userService.addRole(ROOT_EMAIL, root.getId(), Role.ADMIN)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("your own roles"); + } + + @Test + void addRoleRejectsGrantingPermissionsTheActorDoesNotHold() { + User editor = new User("Ed", "Itor", "2", "editor@mail.utoronto.ca", "p"); + editor.setId(UUID.randomUUID()); + editor.addRole(Role.BOARD_EDITOR); + when(userRepository.findByEmail(editor.getEmail())).thenReturn(Optional.of(editor)); + when(authorityResolver.resolvePermissions(editor)) + .thenReturn(EnumSet.copyOf(Role.BOARD_EDITOR.getPermissions())); + + UUID id = UUID.randomUUID(); + target(id); + + // BOARD_ADMIN confers device enrollment and screenshots; a plain editor holds neither. + assertThatThrownBy(() -> userService.addRole(editor.getEmail(), id, Role.BOARD_ADMIN)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("board:device:enroll"); + } + + @Test + void addRoleAllowsGrantingASubsetOfWhatTheActorHolds() { + User boardAdmin = new User("B", "A", "3", "boardadmin@mail.utoronto.ca", "p"); + boardAdmin.setId(UUID.randomUUID()); + boardAdmin.addRole(Role.BOARD_ADMIN); + when(userRepository.findByEmail(boardAdmin.getEmail())).thenReturn(Optional.of(boardAdmin)); + when(authorityResolver.resolvePermissions(boardAdmin)) + .thenReturn(EnumSet.copyOf(Role.BOARD_ADMIN.getPermissions())); + + UUID id = UUID.randomUUID(); + target(id); + + User result = userService.addRole(boardAdmin.getEmail(), id, Role.BOARD_EDITOR); + + assertThat(result.hasRole(Role.BOARD_EDITOR)).isTrue(); + } + + @Test + void removeRoleRefusesToStripTheLastGrantHolder() { + rootActor(); + UUID id = UUID.randomUUID(); + target(id).addRole(Role.ROOT); + + // Only this user holds a role conferring iam:role:grant. + when(userRepository.findIdsByRoleIn(any())).thenReturn(new HashSet<>(Set.of(id))); + when(userRepository.findIdsByDirectPermission(Permission.IAM_ROLE_GRANT)) + .thenReturn(new HashSet<>()); + + assertThatThrownBy(() -> userService.removeRole(ROOT_EMAIL, id, Role.ROOT)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("last user holding it"); + } + + @Test + void removeRoleAllowsStrippingRootWhenAnotherHolderRemains() { + User root = rootActor(); + UUID id = UUID.randomUUID(); + target(id).addRole(Role.ROOT); + + when(userRepository.findIdsByRoleIn(any())) + .thenReturn(new HashSet<>(Set.of(id, root.getId()))); + when(userRepository.findIdsByDirectPermission(Permission.IAM_ROLE_GRANT)) + .thenReturn(new HashSet<>()); + + User result = userService.removeRole(ROOT_EMAIL, id, Role.ROOT); + + assertThat(result.hasRole(Role.ROOT)).isFalse(); + } + + /** + * Demotion is an escalation path. Someone with a direct iam:role:grant must not be able + * to strip ROLE_ROOT off every root account and end as the only administrator left — + * the last-holder guard never fires, because their own direct grant keeps the set + * non-empty. + */ + @Test + void removeRoleRejectsStrippingPermissionsTheActorDoesNotHold() { + User grantOnly = new User("G", "Only", "4", "grantonly@mail.utoronto.ca", "p"); + grantOnly.setId(UUID.randomUUID()); + grantOnly.addDirectPermission(Permission.IAM_ROLE_GRANT); + when(userRepository.findByEmail(grantOnly.getEmail())).thenReturn(Optional.of(grantOnly)); + when(authorityResolver.resolvePermissions(grantOnly)) + .thenReturn(EnumSet.of(Permission.IAM_ROLE_GRANT)); + + UUID victim = UUID.randomUUID(); + target(victim).addRole(Role.ROOT); + + assertThatThrownBy(() -> userService.removeRole(grantOnly.getEmail(), victim, Role.ROOT)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("cannot remove"); + } + + @Test + void revokePermissionRejectsRevokingWhatTheActorDoesNotHold() { + User editor = new User("Ed", "Itor", "5", "ed@mail.utoronto.ca", "p"); + editor.setId(UUID.randomUUID()); + editor.addRole(Role.BOARD_EDITOR); + editor.addDirectPermission(Permission.IAM_ROLE_GRANT); + when(userRepository.findByEmail(editor.getEmail())).thenReturn(Optional.of(editor)); + Set held = EnumSet.copyOf(Role.BOARD_EDITOR.getPermissions()); + held.add(Permission.IAM_ROLE_GRANT); + when(authorityResolver.resolvePermissions(editor)).thenReturn(held); + + UUID victim = UUID.randomUUID(); + target(victim).addDirectPermission(Permission.BOARD_COMMAND_INSPECT); + + assertThatThrownBy(() -> userService.revokePermission( + editor.getEmail(), victim, Permission.BOARD_COMMAND_INSPECT)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("board:command:inspect"); + } + + /** + * The lockout guard must look at what the target would actually be left holding. A ROOT + * who also carries a direct iam:role:grant keeps it after ROLE_ROOT comes off, so that + * removal is nobody's lockout and must not be refused. + */ + @Test + void removeRoleIsAllowedWhenTheTargetKeepsGrantByAnotherPath() { + rootActor(); + UUID id = UUID.randomUUID(); + User victim = target(id); + victim.addRole(Role.ROOT); + when(userRepository.findDirectPermissions(id)) + .thenReturn(Set.of(Permission.IAM_ROLE_GRANT)); + + User result = userService.removeRole(ROOT_EMAIL, id, Role.ROOT); + + assertThat(result.hasRole(Role.ROOT)).isFalse(); + // No holder counting at all: the target keeps the capability, so there is no + // lockout question to ask. Deliberately left unstubbed to assert that. + verify(userRepository, never()).findIdsByRoleIn(any()); + verify(userRepository, never()).findIdsByDirectPermission(any()); + } + + @Test + void grantPermissionAddsADirectGrant() { + rootActor(); + UUID id = UUID.randomUUID(); + target(id); + + User result = userService.grantPermission(ROOT_EMAIL, id, Permission.BOARD_COMMAND_INSPECT); + + assertThat(result.hasDirectPermission(Permission.BOARD_COMMAND_INSPECT)).isTrue(); + } + + @Test + void revokePermissionRejectsAPermissionThatCameFromARole() { + rootActor(); + UUID id = UUID.randomUUID(); + target(id).addRole(Role.BOARD_EDITOR); + + assertThatThrownBy(() -> + userService.revokePermission(ROOT_EMAIL, id, Permission.BOARD_POSTER_WRITE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("removing the role"); + } + @Test void requestPasswordResetSendsEmailWhenUserExists() { User user = new User("A", "B", "1", "a@b.ca", "p"); From d63a0ad7ebe1fb29229bcef4d76f8bb3b84cb8c7 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Sat, 8 Aug 2026 00:09:40 -0400 Subject: [PATCH 38/52] small changes to models --- openapi.yaml | 41 ++++++- .../controller/AdminController.java | 24 ++-- .../dto/auth/request/CreateUserRequest.java | 65 ++++++++++ .../model/board/frames/FrameType.java | 35 ++++++ .../lensbridge/service/UserService.java | 115 +++++++++++++++--- .../model/board/frames/FrameTypeJsonTest.java | 72 +++++++++++ .../lensbridge/service/UserServiceTest.java | 104 ++++++++++++++++ 7 files changed, 429 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java diff --git a/openapi.yaml b/openapi.yaml index 42ea025..fc58f4d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1560,12 +1560,17 @@ paths: - admin-controller /api/admin/user/create: post: + description: "Omit the password — the normal case — and the account is created\ + \ disabled and the user is emailed a password reset link; setting a password\ + \ through that link is what enables the account. Supply a password only when\ + \ the account has to be usable immediately: it is then enabled on creation\ + \ and no email is sent." operationId: createUser requestBody: content: application/json: schema: - $ref: "#/components/schemas/SignupRequest" + $ref: "#/components/schemas/CreateUserRequest" required: true responses: "200": @@ -1580,6 +1585,7 @@ paths: schema: $ref: "#/components/schemas/MessageResponse" description: Request failed; body carries a human-readable message + summary: Create an account for someone else tags: - admin-controller /api/admin/user/verify: @@ -3250,6 +3256,39 @@ components: - imageFile - startTime - title + CreateUserRequest: + type: object + properties: + email: + type: string + maxLength: 50 + minLength: 0 + firstName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + lastName: + type: string + maxLength: 20 + minLength: 0 + pattern: "^[A-Za-z]+([ '-][A-Za-z]+)*$" + password: + type: string + description: Optional. Omit it to create a disabled account and email the + user a password reset link; supply it to create an account that can sign + in right away. + maxLength: 40 + minLength: 6 + studentNumber: + type: string + maxLength: 10 + minLength: 0 + required: + - email + - firstName + - lastName + - studentNumber DailyScheduleFrameConfig: allOf: - $ref: "#/components/schemas/FrameConfig" diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 9daf446..dc429a9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -3,7 +3,7 @@ import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; -import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; +import com.ibrasoft.lensbridge.dto.auth.request.CreateUserRequest; import com.ibrasoft.lensbridge.dto.auth.request.VerifyUserRequest; import com.ibrasoft.lensbridge.dto.auth.response.PermissionResponse; import com.ibrasoft.lensbridge.dto.auth.response.RoleDefinitionResponse; @@ -232,20 +232,24 @@ public ResponseEntity verifyUser(@Valid @RequestBody VerifyUser "User verified successfully"); } + @Operation(operationId = "createUser", + summary = "Create an account for someone else", + description = "Omit the password — the normal case — and the account is created disabled " + + "and the user is emailed a password reset link; setting a password through that " + + "link is what enables the account. Supply a password only when the account has " + + "to be usable immediately: it is then enabled on creation and no email is sent.") @PostMapping("/user/create") @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_WRITE + "')") - public ResponseEntity createUser(@RequestBody SignupRequest signUpRequest, HttpServletRequest request) { - return executeCreationAction(signUpRequest, request, - (signupRequest) -> { - User newUser = userService.createUser(signupRequest, false); - userService.verifyDirectly(newUser.getId()); - userService.requestPasswordReset(newUser.getEmail()); - return newUser; - }, + public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest createUserRequest, HttpServletRequest request) { + return executeCreationAction(createUserRequest, request, + userService::createUserByAdmin, AuditAction.ADD_USER, "User", User::getId, - "User created successfully: " + signUpRequest.getEmail()); + createUserRequest.hasPassword() + ? "User created successfully: " + createUserRequest.getEmail() + : "User created and invited: " + createUserRequest.getEmail() + + ". The account stays disabled until they set a password from the email."); } @Operation(operationId = "getAvailableRoles", diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java new file mode 100644 index 0000000..b760f0f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/CreateUserRequest.java @@ -0,0 +1,65 @@ +package com.ibrasoft.lensbridge.dto.auth.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * An administrator creating an account for somebody else. + *

+ * Distinct from {@link SignupRequest} solely because of the password: on self-signup it is + * mandatory, and here it is optional. Relaxing it on {@code SignupRequest} instead would + * have let anyone register a passwordless account through the public endpoint. + *

+ * Leaving the password out is the normal case — the account is created disabled and the + * invitee sets their own password from the reset email. Supplying one is for the rare case + * where the account has to be usable immediately, and hands the administrator a credential + * they had to invent and then transmit somehow. + */ +@Data +public class CreateUserRequest { + + @NotBlank + @Size(max = 20) + @Pattern(regexp = "^[A-Za-z]+([ '-][A-Za-z]+)*$", message = + "First name can only contain letters, spaces, hyphens, and apostrophes") + private String firstName; + + @NotBlank + @Size(max = 20) + @Pattern(regexp = "^[A-Za-z]+([ '-][A-Za-z]+)*$", message = "Last name can only contain letters, spaces, hyphens, and apostrophes") + private String lastName; + + @NotBlank + @Size(max = 10) + private String studentNumber; + + @NotBlank + @Size(max = 50) + @Email + // @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]*\\.utoronto\\.ca$", message = "Email must be a valid University of Toronto email address (*.utoronto.ca)") + private String email; + + @Size(min = 6, max = 40, message = "Password must be between 6 and 40 characters long") + @Schema(description = "Optional. Omit it to create a disabled account and email the user a " + + "password reset link; supply it to create an account that can sign in right away.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String password; + + public void setEmail(String email) { + this.email = email.toLowerCase(); + } + + /** Blank is treated as absent, so {@code ""} invites rather than failing @Size validation. */ + public void setPassword(String password) { + this.password = (password == null || password.isBlank()) ? null : password; + } + + @Schema(hidden = true) + public boolean hasPassword() { + return password != null; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java index 59ad3ce..1b3ae02 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java @@ -1,5 +1,22 @@ package com.ibrasoft.lensbridge.model.board.frames; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; +import java.util.Optional; + +/** + * The kind of frame the assembler emitted, as it appears in + * {@code FrameDefinition.frameType}. + *

+ * {@code @JsonValue} is crucial, else Jackson will serialize the enum name instead of the wire + * string, which makes things like {@code POSTER} appear in the database and on the wire instead of + * {@code poster}. + *

+ * Mirrors {@link com.ibrasoft.lensbridge.model.board.Audience}, which had the same + * shape and got this right. + */ public enum FrameType { POSTER("poster"), EVENT_LIST("event_list"), @@ -13,6 +30,24 @@ public enum FrameType { this.typeName = typeName; } + public static Optional from(String s) { + if (s == null) return Optional.empty(); + return Arrays.stream(values()) + .filter(t -> t.name().equalsIgnoreCase(s) || t.typeName.equalsIgnoreCase(s)) + .findFirst(); + } + + @JsonValue + public String toJson() { + return typeName; + } + + /** Accepts either spelling, so anything persisted or sent as {@code POSTER} still binds. */ + @JsonCreator + public static FrameType fromJson(String s) { + return from(s).orElseThrow(() -> new IllegalArgumentException("Unknown frame type: " + s)); + } + @Override public String toString() { return typeName; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java index 8af4f1c..8670086 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/UserService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/UserService.java @@ -1,14 +1,17 @@ package com.ibrasoft.lensbridge.service; +import java.security.SecureRandom; import java.time.Instant; import java.util.Arrays; import java.util.EnumSet; import java.util.HashSet; +import java.util.HexFormat; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional; import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.CreateUserRequest; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; @@ -45,6 +49,8 @@ public class UserService { private final RefreshTokenService refreshTokenService; private final AuthorityResolver authorityResolver; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + @Value("${frontend.baseurl}") private String frontendBaseUrl; @@ -94,21 +100,13 @@ public User saveUser(User user) { public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { log.info("Creating new user: {}", signUpRequest.getEmail()); - if (existsByEmail(signUpRequest.getEmail()) || existsByStudentNumber(signUpRequest.getStudentNumber())) { - throw new IllegalArgumentException("User with this email or student number already exists"); - } - - User user = new User( + User savedUser = register( signUpRequest.getFirstName(), signUpRequest.getLastName(), signUpRequest.getStudentNumber(), signUpRequest.getEmail(), - signUpRequest.getPassword() == null ? null : passwordEncoder.encode(signUpRequest.getPassword()) + () -> signUpRequest.getPassword() == null ? null : passwordEncoder.encode(signUpRequest.getPassword()) ); - user.setRoles(new HashSet<>()); - user.addRole(Role.USER); - - User savedUser = saveUser(user); if (sendConfirmEmail) { String token = verificationTokenService.generateEmailVerificationToken(savedUser); @@ -117,7 +115,7 @@ public User createUser(SignupRequest signUpRequest, boolean sendConfirmEmail) { savedUser.getFirstName(), verificationUrl); } - log.info("User created: {}", user.getEmail()); + log.info("User created: {}", savedUser.getEmail()); return savedUser; } @@ -125,6 +123,77 @@ public User createUser(SignupRequest signUpRequest) { return createUser(signUpRequest, true); } + /** + * Creates an account on an administrator's behalf. + *

+ * With no password supplied the account is created disabled — {@code verifiedAt} + * stays null, which is what {@code UserDetailsServiceImpl} reports as + * {@code disabled(true)} — and the invitee gets a password reset email. Setting a password + * through that link is what turns the account on, so the administrator never handles a + * credential and never has to transmit one out of band. + *

+ * A supplied password is taken as "make this usable now", so the account is marked verified + * immediately and no email goes out. Nothing else here needs an unverified branch: the + * address was chosen by an administrator, not self-asserted by a stranger, so email + * verification is not what is being established. + */ + public User createUserByAdmin(CreateUserRequest request) { + log.info("Admin creating user: {} (password supplied: {})", request.getEmail(), request.hasPassword()); + + User user = register( + request.getFirstName(), + request.getLastName(), + request.getStudentNumber(), + request.getEmail(), + () -> request.hasPassword() ? passwordEncoder.encode(request.getPassword()) : unusablePassword() + ); + + if (request.hasPassword()) { + user.setVerifiedAt(Instant.now()); + user = saveUser(user); + log.info("Admin created enabled user: {}", user.getEmail()); + } else { + sendPasswordResetEmail(user); + log.info("Admin created disabled user {}, password reset email sent", user.getEmail()); + } + + return user; + } + + /** + * Duplicate check, {@code ROLE_USER}, save. Shared by self-signup and admin creation. + * + * @param passwordHash supplied lazily so a rejected duplicate does not pay for a bcrypt round + * first — and so the check cannot be reordered after it by accident + */ + private User register(String firstName, String lastName, String studentNumber, String email, + Supplier passwordHash) { + if (existsByEmail(email) || existsByStudentNumber(studentNumber)) { + throw new IllegalArgumentException("User with this email or student number already exists"); + } + + User user = new User(firstName, lastName, studentNumber, email, passwordHash.get()); + user.setRoles(new HashSet<>()); + user.addRole(Role.USER); + + return saveUser(user); + } + + /** + * A hash nothing can match, for an account whose owner has not chosen a password yet. + *

+ * {@code User.passwordHash} is {@code @NotBlank} and {@code nullable = false}, so a null is + * not storable; a placeholder rather than a schema change also keeps + * {@code UserDetailsServiceImpl} away from a null password, which the Spring + * {@code User} builder rejects outright. The input is 32 random bytes that are never + * retained, so the only route into the account is the reset link. + */ + private String unusablePassword() { + byte[] secret = new byte[32]; + SECURE_RANDOM.nextBytes(secret); + return passwordEncoder.encode(HexFormat.of().formatHex(secret)); + } + public User verifyUserEmail(String plaintextToken) { return verificationTokenService.consumeEmailVerification(plaintextToken); } @@ -354,21 +423,35 @@ private Set directPermissionsOf(User target) { } public void requestPasswordReset(String email) { - userRepository.findByEmail(email).ifPresent(user -> { - String token = verificationTokenService.generatePasswordResetToken(user); - String resetUrl = frontendBaseUrl + "/reset-password?token=" + token; - emailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), resetUrl); - }); + userRepository.findByEmail(email).ifPresent(this::sendPasswordResetEmail); + } + + private void sendPasswordResetEmail(User user) { + String token = verificationTokenService.generatePasswordResetToken(user); + String resetUrl = frontendBaseUrl + "/reset-password?token=" + token; + emailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), resetUrl); } public boolean validateResetToken(String token) { return verificationTokenService.isValidResetToken(token); } + /** + * Consumes a reset token and sets the new password. + *

+ * A still-unverified account is verified here as well, and that is load-bearing for + * {@link #createUserByAdmin}: an invited account is disabled until verified, so without + * this the invitee would set a password through the emailed link and still be refused at + * sign-in, with no way out. Following a link sent to the address proves control of it — + * the same thing the verification email proves — so nothing is being taken on trust. + */ public User resetPassword(String token, String newPassword) { VerificationToken verificationToken = verificationTokenService.consumePasswordReset(token); User user = verificationToken.getUser(); user.setPassword(passwordEncoder.encode(newPassword)); + if (!user.isVerified()) { + user.setVerifiedAt(Instant.now()); + } User saved = saveUser(user); refreshTokenService.revokeAllUserTokens(user.getId()); return saved; diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java new file mode 100644 index 0000000..ec84586 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java @@ -0,0 +1,72 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * FrameType must serialise as the lowercase form the OpenAPI spec enumerates. + * + * The same defect {@code AudienceJsonTest} pins, in the enum that was missed: + * springdoc documented {@code toString()} while Jackson used {@code name()}, so + * {@code FrameDefinition.frameType} was published as {@code poster} and sent as + * {@code POSTER}. The admin console rendered every frame as an unlabelled grey + * box, and the kiosk's poster prefetch — which compares against {@code "poster"} + * — silently matched nothing. + * + * {@code FrameTypeTest} already asserted {@code toString()}, and passed the whole + * time. That is the trap: {@code toString()} is not the serialised form unless + * something says it is. + */ +class FrameTypeJsonTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void serialisesAsLowercase() throws Exception { + assertEquals("\"poster\"", mapper.writeValueAsString(FrameType.POSTER)); + assertEquals("\"event_list\"", mapper.writeValueAsString(FrameType.EVENT_LIST)); + assertEquals("\"daily_schedule\"", mapper.writeValueAsString(FrameType.DAILY_SCHEDULE)); + assertEquals("\"next_prayer\"", mapper.writeValueAsString(FrameType.NEXT_PRAYER)); + assertEquals("\"jummah\"", mapper.writeValueAsString(FrameType.JUMMAH)); + assertEquals("\"islamic_quote\"", mapper.writeValueAsString(FrameType.ISLAMIC_QUOTE)); + } + + @Test + void readsTheDocumentedLowercaseForm() throws Exception { + assertEquals(FrameType.POSTER, mapper.readValue("\"poster\"", FrameType.class)); + assertEquals(FrameType.EVENT_LIST, mapper.readValue("\"event_list\"", FrameType.class)); + } + + /** Consumers written against the previous uppercase behaviour must keep working. */ + @Test + void stillReadsTheLegacyUppercaseForm() throws Exception { + assertEquals(FrameType.POSTER, mapper.readValue("\"POSTER\"", FrameType.class)); + assertEquals(FrameType.ISLAMIC_QUOTE, mapper.readValue("\"Islamic_Quote\"", FrameType.class)); + } + + @Test + void rejectsUnknownValues() { + assertThrows(Exception.class, () -> mapper.readValue("\"carousel\"", FrameType.class)); + } + + @Test + void roundTrips() throws Exception { + for (FrameType type : FrameType.values()) { + assertEquals(type, mapper.readValue(mapper.writeValueAsString(type), FrameType.class)); + } + } + + /** + * The value the spec publishes is the value a client receives. Pinned against + * the enum rather than a literal list so a new frame type cannot skip it. + */ + @Test + void serialisedFormMatchesToString() throws Exception { + for (FrameType type : FrameType.values()) { + assertEquals("\"" + type + "\"", mapper.writeValueAsString(type)); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java index ce854b5..fb607ba 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/UserServiceTest.java @@ -1,6 +1,7 @@ package com.ibrasoft.lensbridge.service; import com.ibrasoft.lensbridge.dto.auth.request.ChangePasswordRequest; +import com.ibrasoft.lensbridge.dto.auth.request.CreateUserRequest; import com.ibrasoft.lensbridge.dto.auth.request.SignupRequest; import com.ibrasoft.lensbridge.dto.auth.request.UpdateProfileRequest; import com.ibrasoft.lensbridge.model.auth.Permission; @@ -13,10 +14,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.util.EnumSet; import java.util.HashSet; import java.util.Optional; @@ -127,6 +130,78 @@ void createUserRejectsDuplicateStudentNumber() { .isInstanceOf(IllegalArgumentException.class); } + private CreateUserRequest adminCreate() { + CreateUserRequest req = new CreateUserRequest(); + req.setFirstName("Jane"); + req.setLastName("Doe"); + req.setStudentNumber("1000001"); + req.setEmail("jane@mail.utoronto.ca"); + return req; + } + + @Test + void createUserByAdminWithoutPasswordLeavesAccountDisabledAndEmailsResetLink() { + when(passwordEncoder.encode(anyString())).thenReturn("UNUSABLE"); + when(verificationTokenService.generatePasswordResetToken(any(User.class))).thenReturn("rtok"); + + User created = userService.createUserByAdmin(adminCreate()); + + assertThat(created.isVerified()).isFalse(); + assertThat(created.getRoles()).containsExactly(Role.USER); + verify(emailService).sendPasswordResetEmail(eqEmail(), any(), + org.mockito.ArgumentMatchers.contains("token=rtok")); + verify(emailService, never()).sendVerificationEmail(anyString(), anyString(), anyString()); + } + + @Test + void createUserByAdminWithoutPasswordStoresAHashNoInputCanMatch() { + ArgumentCaptor encoded = ArgumentCaptor.forClass(String.class); + when(passwordEncoder.encode(encoded.capture())).thenReturn("UNUSABLE"); + + User created = userService.createUserByAdmin(adminCreate()); + + // Never null: passwordHash is @NotBlank and nullable = false. + assertThat(created.getPassword()).isEqualTo("UNUSABLE"); + assertThat(encoded.getValue()).hasSize(64); // 32 random bytes, hex + } + + @Test + void createUserByAdminWithPasswordEnablesAccountAndSendsNothing() { + CreateUserRequest req = adminCreate(); + req.setPassword("password1"); + when(passwordEncoder.encode("password1")).thenReturn("ENC"); + + User created = userService.createUserByAdmin(req); + + assertThat(created.getPassword()).isEqualTo("ENC"); + assertThat(created.isVerified()).isTrue(); + verifyNoInteractions(emailService); + verifyNoInteractions(verificationTokenService); + } + + @Test + void createUserByAdminTreatsBlankPasswordAsAbsent() { + CreateUserRequest req = adminCreate(); + req.setPassword(" "); + when(passwordEncoder.encode(anyString())).thenReturn("UNUSABLE"); + + User created = userService.createUserByAdmin(req); + + assertThat(created.isVerified()).isFalse(); + verify(emailService).sendPasswordResetEmail(eqEmail(), any(), anyString()); + } + + @Test + void createUserByAdminRejectsDuplicateEmail() { + when(userRepository.existsByEmail("jane@mail.utoronto.ca")).thenReturn(true); + + assertThatThrownBy(() -> userService.createUserByAdmin(adminCreate())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already exists"); + verify(userRepository, never()).save(any()); + verifyNoInteractions(emailService); + } + @Test void verifyDirectlySetsVerifiedAt() { UUID id = UUID.randomUUID(); @@ -431,6 +506,35 @@ void resetPasswordEncodesNewPasswordAndRevokesTokens() { verify(refreshTokenService).revokeAllUserTokens(userId); } + @Test + void resetPasswordEnablesAStillUnverifiedAccount() { + // The admin-invite path: without this the invitee sets a password and is still disabled. + User user = new User("A", "B", "1", "a@b.ca", "unusable"); + user.setId(UUID.randomUUID()); + VerificationToken vt = VerificationToken.builder().user(user).build(); + when(verificationTokenService.consumePasswordReset("ptok")).thenReturn(vt); + when(passwordEncoder.encode("newpass")).thenReturn("ENC"); + + User result = userService.resetPassword("ptok", "newpass"); + + assertThat(result.isVerified()).isTrue(); + } + + @Test + void resetPasswordLeavesAnExistingVerificationTimestampAlone() { + User user = new User("A", "B", "1", "a@b.ca", "old"); + user.setId(UUID.randomUUID()); + Instant verifiedAt = Instant.now().minusSeconds(86400); + user.setVerifiedAt(verifiedAt); + VerificationToken vt = VerificationToken.builder().user(user).build(); + when(verificationTokenService.consumePasswordReset("ptok")).thenReturn(vt); + when(passwordEncoder.encode("newpass")).thenReturn("ENC"); + + User result = userService.resetPassword("ptok", "newpass"); + + assertThat(result.getVerifiedAt()).isEqualTo(verifiedAt); + } + @Test void changePasswordRejectsWrongCurrentPassword() { User user = new User("A", "B", "1", "a@b.ca", "hash"); From 761da3e18e09919c6dc2ea9ea4dd63841722c082 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 12:35:51 -0400 Subject: [PATCH 39/52] Add new endpoints for managing device audience and display name --- .../controller/BoardAdminController.java | 19 ++++++++++++++++++- .../board/request/UpdateDeviceRequest.java | 17 +++++++++++++++++ .../lensbridge/service/BoardService.java | 15 +++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 7cce6f8..4330099 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -4,14 +4,17 @@ import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdatePosterRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateTickerRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; +import com.ibrasoft.lensbridge.dto.board.response.DeviceSummary; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.WeeklyContent; @@ -33,7 +36,6 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.time.Instant; import java.util.List; import java.util.UUID; import io.swagger.v3.oas.annotations.Operation; @@ -361,6 +363,21 @@ public ResponseEntity refreshSignboard(HttpServletRequest reque return ResponseEntity.ok(new MessageResponse("Refresh sent to all MusallahBoard instances")); } + @PatchMapping("/devices/{deviceId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_WRITE + "')") + public ResponseEntity updateDevice( + @PathVariable UUID deviceId, + @Valid @RequestBody UpdateDeviceRequest updateRequest, + HttpServletRequest request) { + + log.info("Admin updating device: {}", deviceId); + Device updated = boardService.updateDevice(deviceId, updateRequest); + boardStream.configChanged(deviceId); + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_BOARD_CONFIG, "Device", deviceId, request.getRemoteAddr()); + + return ResponseEntity.ok(DeviceSummary.of(updated)); + } + private String getCurrentUserEmail() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication != null ? authentication.getName() : "unknown"; diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java new file mode 100644 index 0000000..aec036f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java @@ -0,0 +1,17 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateDeviceRequest { + private Audience audience; + private String displayName; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 83bbf58..25cdd12 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateTickerRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; @@ -247,4 +248,18 @@ public void deleteEvent(UUID eventId) { log.info("Deleted event: id={}", eventId); boardStream.contentChanged("events"); } + + // ================== Device Management Changes ==================== + + public Device updateDevice(UUID deviceId, UpdateDeviceRequest request) { + Device existing = deviceRepository.findById(deviceId) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Device not found: " + deviceId))); + Patch.apply(request.getDisplayName(), existing::setDisplayName); + Patch.apply(request.getAudience(), existing::setAudience); + log.info("Updated device: id={}", deviceId); + return deviceRepository.save(existing); + } + } From 384b4715ea5ca8be8de293e928e84e7c1e0774f1 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 14:54:04 -0400 Subject: [PATCH 40/52] All frames are now backend-managed, minus socials which is coming next --- .gitignore | 1 + openapi.yaml | 160 ++++++++------ .../controller/MusallahBoardController.java | 12 - .../request/UpdateBoardConfigRequest.java | 28 +++ .../board/request/UpdateDeviceRequest.java | 17 ++ .../dto/board/response/frames/DayBucket.java | 30 +++ .../exception/GlobalExceptionHandler.java | 159 ++++++++++--- .../model/board/embedded/DeviceConfig.java | 35 +++ .../model/board/frames/AgendaFrameConfig.java | 37 ++++ .../frames/DailyScheduleFrameConfig.java | 12 - .../board/frames/EventListFrameConfig.java | 12 - .../model/board/frames/FrameConfig.java | 15 +- .../model/board/frames/FrameDefinition.java | 22 +- .../model/board/frames/FrameSlot.java | 8 - .../model/board/frames/FrameType.java | 4 +- .../board/frames/InstagramFrameConfig.java | 15 ++ .../board/frames/NextPrayerFrameConfig.java | 15 ++ .../lensbridge/service/BoardService.java | 23 +- .../lensbridge/service/PosterService.java | 26 --- .../service/board/BoardPayloadAssembler.java | 58 +---- .../board/producer/AgendaFrameProducer.java | 114 ++++++++++ .../service/board/producer/FrameProducer.java | 17 ++ .../producer/InstagramFrameProducer.java | 32 +++ .../producer/NextPrayerFrameProducer.java | 43 ++++ .../PosterFrameProducer.java} | 25 ++- .../WeeklyContentFrameProducer.java} | 32 ++- .../DailyScheduleFrameTransformer.java | 47 ---- .../EventListFrameTransformer.java | 48 ---- .../board/transformer/FrameTransformer.java | 10 - .../BoardAdminControllerUpdateDeviceTest.java | 122 ++++++++++ .../exception/GlobalExceptionHandlerTest.java | 115 ++++++++++ .../model/board/frames/FrameTypeJsonTest.java | 5 +- .../model/board/frames/FrameTypeTest.java | 5 +- .../lensbridge/service/BoardServiceTest.java | 126 +++++++++++ .../lensbridge/service/PosterServiceTest.java | 18 -- .../producer/AgendaFrameProducerTest.java | 209 ++++++++++++++++++ .../producer/InstagramFrameProducerTest.java | 28 +++ .../producer/NextPrayerFrameProducerTest.java | 65 ++++++ .../PosterFrameProducerTest.java} | 21 +- .../WeeklyContentFrameProducerTest.java} | 19 +- .../DailyScheduleFrameTransformerTest.java | 92 -------- .../EventListFrameTransformerTest.java | 91 -------- 42 files changed, 1389 insertions(+), 584 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java rename src/main/java/com/ibrasoft/lensbridge/service/board/{transformer/PosterFrameTransformer.java => producer/PosterFrameProducer.java} (55%) rename src/main/java/com/ibrasoft/lensbridge/service/board/{transformer/WeeklyContentFrameTransformer.java => producer/WeeklyContentFrameProducer.java} (74%) delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducerTest.java rename src/test/java/com/ibrasoft/lensbridge/service/board/{transformer/PosterFrameTransformerTest.java => producer/PosterFrameProducerTest.java} (79%) rename src/test/java/com/ibrasoft/lensbridge/service/board/{transformer/WeeklyContentFrameTransformerTest.java => producer/WeeklyContentFrameProducerTest.java} (92%) delete mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java diff --git a/.gitignore b/.gitignore index f41c965..fae7528 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules HELP.md target/ +docs/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/openapi.yaml b/openapi.yaml index fc58f4d..7f9ef09 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -547,6 +547,36 @@ paths: summary: Fetch a single board device tags: - device-admin-controller + patch: + operationId: updateDevice + parameters: + - in: path + name: deviceId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateDeviceRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceSummary" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller /api/admin/board/devices/{deviceId}/commands: get: operationId: listDeviceCommands @@ -2495,36 +2525,6 @@ paths: description: Request failed; body carries a human-readable message tags: - musallah-board-controller - /api/musallah/posters: - get: - operationId: getActivePosterFrames - parameters: - - in: query - name: audience - required: true - schema: - type: string - enum: - - brothers - - sisters - - both - responses: - "200": - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/FrameDefinition" - description: OK - default: - content: - application/json: - schema: - $ref: "#/components/schemas/MessageResponse" - description: Request failed; body carries a human-readable message - tags: - - musallah-board-controller /api/musallah/weekly-content: get: operationId: getCurrentWeeklyContent @@ -2965,6 +2965,17 @@ components: uuid: type: string format: uuid + AgendaFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + days: + type: array + items: + $ref: "#/components/schemas/DayBucket" + heading: + type: string AgentEnrollRequest: type: object properties: @@ -3289,20 +3300,22 @@ components: - firstName - lastName - studentNumber - DailyScheduleFrameConfig: - allOf: - - $ref: "#/components/schemas/FrameConfig" - - type: object - properties: - events: - type: array - items: - $ref: "#/components/schemas/EventView" - heading: - type: string + DayBucket: + type: object + properties: + date: + type: string + format: date + events: + type: array + items: + $ref: "#/components/schemas/EventView" DeviceConfig: type: object properties: + agendaDurationSeconds: + type: integer + format: int32 darkModeAfterIsha: type: boolean enableScrollingMessage: @@ -3312,6 +3325,9 @@ components: format: uuid location: $ref: "#/components/schemas/Location" + nextPrayerDurationSeconds: + type: integer + format: int32 scrollingMessages: type: array items: @@ -3347,17 +3363,6 @@ components: revokedAt: type: string format: date-time - EventListFrameConfig: - allOf: - - $ref: "#/components/schemas/FrameConfig" - - type: object - properties: - events: - type: array - items: - $ref: "#/components/schemas/EventView" - heading: - type: string EventView: type: object properties: @@ -3391,30 +3396,23 @@ components: format: int32 frameConfig: oneOf: - - $ref: "#/components/schemas/DailyScheduleFrameConfig" - - $ref: "#/components/schemas/EventListFrameConfig" + - $ref: "#/components/schemas/AgendaFrameConfig" + - $ref: "#/components/schemas/InstagramFrameConfig" - $ref: "#/components/schemas/IslamicQuoteFrameConfig" - $ref: "#/components/schemas/JummahFrameConfig" + - $ref: "#/components/schemas/NextPrayerFrameConfig" - $ref: "#/components/schemas/PosterFrameConfig" + frameId: + type: string frameType: type: string enum: - poster - - event_list - - daily_schedule - next_prayer + - agenda + - instagram - jummah - islamic_quote - priority: - type: integer - format: int32 - slot: - type: string - enum: - - PRIMARY - - TICKER - - SIDEBAR - - OVERLAY GalleryItemDto: type: object properties: @@ -3436,6 +3434,9 @@ components: type: string type: type: string + InstagramFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" IslamicQuote: type: object properties: @@ -3653,6 +3654,9 @@ components: $ref: "#/components/schemas/FrameDefinition" weather: $ref: "#/components/schemas/JsonNode" + NextPrayerFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" PageAdminUploadDto: type: object properties: @@ -4019,12 +4023,22 @@ components: UpdateBoardConfigRequest: type: object properties: + agendaDurationSeconds: + type: integer + format: int32 + maximum: 120 + minimum: 0 darkModeAfterIsha: type: boolean enableScrollingMessage: type: boolean location: $ref: "#/components/schemas/Location" + nextPrayerDurationSeconds: + type: integer + format: int32 + maximum: 120 + minimum: 5 scrollingMessages: type: array items: @@ -4054,6 +4068,20 @@ components: startTime: type: string format: date-time + UpdateDeviceRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + displayName: + type: string + maxLength: 255 + minLength: 0 + pattern: .*\S.* UpdatePosterRequest: type: object properties: diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index 07013df..a7d8229 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -5,9 +5,7 @@ import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.WeeklyContent; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.service.BoardService; -import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +23,6 @@ @Slf4j public class MusallahBoardController { - private final PosterService posterService; private final BoardService boardService; private final BoardPayloadAssembler payloadAssembler; @@ -61,15 +58,6 @@ public ResponseEntity getWeeklyContent( .orElse(ResponseEntity.notFound().build()); } - // ==================== Poster Frames ==================== - - @GetMapping("/posters") - public ResponseEntity> getActivePosterFrames(@RequestParam Audience audience) { - log.debug("Musallah board fetching active poster frames for audience: {}", audience); - List frames = posterService.getActivePosterFrameDefinitions(audience); - return ResponseEntity.ok(frames); - } - // ==================== Calendar Events ==================== @GetMapping("/events") diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java index 6f12ab6..2f2ccad 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -1,6 +1,10 @@ package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.Location; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.*; import java.util.List; @@ -16,4 +20,28 @@ public class UpdateBoardConfigRequest { * Typically a social media page, but could be any URL */ private String socialUrl; + + /** + * Seconds to pin the agenda slide for, or {@code 0} to hand the decision back to the board + * ("auto"). + * + * Zero rather than null carries that meaning because this endpoint patches by skipping + * nulls — null already means "leave it alone", so it cannot also mean "clear it". Same + * reason {@link #socialUrl} treats the empty string as a clear. + * + * The 1–4 gap is rejected in {@code BoardService}: bean validation cannot express + * "zero or at least five" without a custom constraint that would leak a bogus property + * into the generated schema. + */ + @Min(value = 0, message = "agendaDurationSeconds must be 0 (auto) or between 5 and 120 seconds") + @Max(value = DeviceConfig.MAX_SLIDE_SECONDS, + message = "agendaDurationSeconds must be 0 (auto) or between 5 and 120 seconds") + private Integer agendaDurationSeconds; + + /** Seconds to pin the next-prayer countdown for. No auto mode — the slide's content is fixed. */ + @Min(value = DeviceConfig.MIN_SLIDE_SECONDS, + message = "nextPrayerDurationSeconds must be between 5 and 120 seconds") + @Max(value = DeviceConfig.MAX_SLIDE_SECONDS, + message = "nextPrayerDurationSeconds must be between 5 and 120 seconds") + private Integer nextPrayerDurationSeconds; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java index aec036f..094d2ca 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateDeviceRequest.java @@ -2,16 +2,33 @@ import com.ibrasoft.lensbridge.model.board.Audience; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/** + * Partial update: a null field means "leave it alone", so nothing here is + * {@code @NotNull}. The constraints below therefore describe what a supplied + * value must look like — Bean Validation skips them all for null. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateDeviceRequest { + private Audience audience; + + /** + * {@code @Size(min = 1)} alone would let {@code " "} through, and the column is + * only {@code nullable = false} — a whitespace name persists happily and leaves the + * device looking unnamed in every admin listing. The pattern requires at least one + * non-whitespace character; {@code BoardService} trims before persisting. + */ + @Size(max = 255, message = "displayName must be at most 255 characters") + @Pattern(regexp = ".*\\S.*", message = "displayName must not be blank") private String displayName; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java new file mode 100644 index 0000000..31ec9d2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/frames/DayBucket.java @@ -0,0 +1,30 @@ +package com.ibrasoft.lensbridge.dto.board.response.frames; + +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +/** + * One day of the agenda window, in the device's own timezone. + *

+ * Buckets are emitted for every day in the window, including days with no events — the board + * renders an empty day as "nothing scheduled", which it could not distinguish from "day outside + * the window" if empty buckets were dropped. + *

+ * Deliberately carries no {@code isToday} flag. A board can run for days without a payload + * refresh, so any "today" marker computed here goes stale at the first midnight. The client + * compares its own current date against {@link #date} instead. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DayBucket { + + /** Local date in the device's timezone, not UTC. */ + private LocalDate date; + + /** Events overlapping this day, earliest first. Never null; empty for a free day. */ + private List events; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java index e73d2cc..1ab05ba 100644 --- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java @@ -5,27 +5,152 @@ import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.dto.upload.response.FileSizeErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.lang.Nullable; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.util.CollectionUtils; import org.springframework.validation.FieldError; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; +/** + * Extends {@link ResponseEntityExceptionHandler} so Spring's own request-handling + * failures keep their intended status codes. + *

+ * Without it, {@link #handleGenericException} below was the only handler that matched + * them, and {@code ExceptionHandlerExceptionResolver} runs ahead of + * {@code DefaultHandlerExceptionResolver} — so a request that was merely malformed came + * back as a 500 "An unexpected error occurred". That masked a real incident: a client + * sending a signup body with no {@code Content-Type} header produced a 500 rather than a + * 415, and the response said nothing about the header being the problem. + *

+ * Bodies stay as {@link MessageResponse} rather than the {@code ProblemDetail} the parent + * class produces by default, because the frontend reads {@code error.message} everywhere. + * {@link #handleExceptionInternal} performs that conversion in one place. + */ @RestControllerAdvice @Slf4j -public class GlobalExceptionHandler { +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * Rewrites every body produced by the parent class into a {@link MessageResponse}, + * leaving the status and headers it chose alone. + */ + @Override + protected ResponseEntity handleExceptionInternal(Exception ex, @Nullable Object body, + HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) { + ResponseEntity response = super.handleExceptionInternal(ex, body, headers, statusCode, request); + if (response == null) { + return null; // response already committed; the parent declined to write one + } + return new ResponseEntity<>(asMessage(response.getBody(), ex), response.getHeaders(), + response.getStatusCode()); + } + + private static MessageResponse asMessage(@Nullable Object body, Exception ex) { + if (body instanceof MessageResponse message) { + return message; // an override below already picked the wording + } + if (body instanceof ProblemDetail problem && problem.getDetail() != null) { + return new MessageResponse(problem.getDetail()); + } + return new MessageResponse(ex.getMessage() != null ? ex.getMessage() : "Request could not be processed"); + } + + /** + * Names the header that is wrong. The default message reports the media type the + * server inferred, which for a request with no {@code Content-Type} at all is + * {@code application/octet-stream} — accurate, and useless to whoever has to fix + * the client. + */ + @Override + protected ResponseEntity handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + String supported = ex.getSupportedMediaTypes().stream() + .map(MediaType::toString) + .collect(Collectors.joining(", ")); + String message = ex.getContentType() == null + ? "Request is missing a Content-Type header" + : "Content-Type '" + ex.getContentType() + "' is not supported"; + if (!supported.isEmpty()) { + message += "; this endpoint accepts " + supported; + } + log.warn("Rejected {}: {}", request.getDescription(false), message); + return handleExceptionInternal(ex, new MessageResponse(message), headers, status, request); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + String details = ex.getBindingResult().getAllErrors().stream() + .map(error -> error instanceof FieldError fieldError + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return handleExceptionInternal(ex, new MessageResponse(details), headers, status, request); + } + + @Override + protected ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + log.error("Malformed JSON request: ", ex); + return handleExceptionInternal(ex, new MessageResponse("Malformed JSON request"), headers, status, request); + } + + /** + * Sets {@code Allow}, which RFC 9110 requires on a 405 and which the parent + * implementation would have set for us. Overriding it to swap the body means taking + * that on: without the header a client has no machine-readable way to discover that + * the path exists and only its method was wrong. + */ + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + Set supported = ex.getSupportedHttpMethods(); + String message = "HTTP method not supported"; + if (!CollectionUtils.isEmpty(supported)) { + headers.setAllow(supported); + message += "; this endpoint accepts " + supported.stream() + .map(HttpMethod::name) + .sorted() + .collect(Collectors.joining(", ")); + } + return handleExceptionInternal(ex, new MessageResponse(message), headers, status, request); + } + + /** Keeps the pre-existing body shape; this one predates the MessageResponse convention. */ + @Override + protected ResponseEntity handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + log.error("Multipart upload too large: {}", ex.getMessage()); + Map body = new HashMap<>(); + body.put("error", "File Too Large"); + body.put("message", "The uploaded file exceeds the maximum allowed size"); + body.put("status", HttpStatus.PAYLOAD_TOO_LARGE.value()); + return new ResponseEntity<>(body, headers, HttpStatus.PAYLOAD_TOO_LARGE); + } @ExceptionHandler(ApiResponseException.class) public ResponseEntity handleApiResponseException(ApiResponseException ex) { @@ -74,27 +199,6 @@ public ResponseEntity handleEventNotAcceptingUploads(EventNotAcceptingUp .body(ErrorResponse.of(ex.getMessage())); } - @ExceptionHandler(MaxUploadSizeExceededException.class) - public ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) { - log.error("Multipart upload too large: {}", ex.getMessage()); - Map response = new HashMap<>(); - response.put("error", "File Too Large"); - response.put("message", "The uploaded file exceeds the maximum allowed size"); - response.put("status", HttpStatus.PAYLOAD_TOO_LARGE.value()); - return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { - String details = ex.getBindingResult().getAllErrors().stream() - .map(error -> error instanceof FieldError fieldError - ? fieldError.getField() + ": " + fieldError.getDefaultMessage() - : error.getDefaultMessage()) - .sorted() - .collect(Collectors.joining("; ")); - return ResponseEntity.badRequest().body(new MessageResponse(details)); - } - @ExceptionHandler(UsernameNotFoundException.class) public ResponseEntity handleUsernameNotFoundException(UsernameNotFoundException ex) { log.warn("Authentication failed - user not found: {}", ex.getMessage()); @@ -119,17 +223,6 @@ public ResponseEntity handleAuthorizationDeniedException(Author return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new MessageResponse("Access denied.")); } - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { - log.error("Malformed JSON request: ", ex); - return ResponseEntity.badRequest().body(new MessageResponse("Malformed JSON request")); - } - - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(new MessageResponse("HTTP method not supported")); - } - @ExceptionHandler(FileProcessingException.class) public ResponseEntity handleFileProcessingException(FileProcessingException ex) { log.error("File processing error: {}", ex.getMessage(), ex); diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java index 6a8f936..c3ecd73 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -44,4 +44,39 @@ public class DeviceConfig { */ @Column(name = "social_url") private String socialUrl; + + /** Shortest and longest a slide may be pinned for. A 2s slide cannot be read; a 5m one is a hang. */ + public static final int MIN_SLIDE_SECONDS = 5; + public static final int MAX_SLIDE_SECONDS = 120; + + /** Used whenever {@link #nextPrayerDurationSeconds} has no stored value. */ + public static final int DEFAULT_NEXT_PRAYER_DURATION_SECONDS = 12; + + /** + * How long the agenda slide is pinned for, or null for "auto" — the board then grows the + * duration with the number of events it is actually showing. + * + * The one genuinely nullable duration: an agenda with two events and one with a full week + * do not deserve the same dwell time, and only the board knows which it is rendering. + */ + @Column(name = "agenda_duration_seconds") + private Integer agendaDurationSeconds; + + /** + * How long the next-prayer countdown is pinned for. Never null to a caller — see the getter. + * + * The column stays nullable even though the API contract does not, because {@code + * ddl-auto=update} cannot add a NOT NULL column to a table that already has rows: Hibernate + * logs the failure and continues, leaving the column absent and every read broken. Nullable + * plus a defaulting getter reaches the same place without a migration. + */ + @Column(name = "next_prayer_duration_seconds") + private Integer nextPrayerDurationSeconds; + + /** Never returns null, so the payload always carries a concrete duration for this frame. */ + public int getNextPrayerDurationSeconds() { + return nextPrayerDurationSeconds == null + ? DEFAULT_NEXT_PRAYER_DURATION_SECONDS + : nextPrayerDurationSeconds; + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java new file mode 100644 index 0000000..b695e2c --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/AgendaFrameConfig.java @@ -0,0 +1,37 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import com.ibrasoft.lensbridge.dto.board.response.frames.DayBucket; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * The board's rolling agenda: every day from today through the end of the window, bucketed in + * the device's timezone. + *

+ * This replaced the former {@code event_list} and {@code daily_schedule} frames, which queried + * the same events over a 7-day and a 1-day window respectively and differed only in layout. + * Today's events are simply the first bucket. + *

+ * The backend fixes the content of the window; the board advances through it as the day + * passes. Which day is current, which event is next, and how far to scroll all change by the + * minute, far faster than a payload refresh, so they are necessarily client-side. Every time here + * is an absolute {@code Instant} for that reason — a pre-formatted "in 2 hours" would be wrong + * within two hours. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AgendaFrameConfig extends FrameConfig { + + private String heading; + + /** One entry per day in the window, chronological, including days with no events. */ + private List days; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java deleted file mode 100644 index 50888f4..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/DailyScheduleFrameConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import lombok.*; -import java.util.List; - -@Data @Builder @NoArgsConstructor @AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class DailyScheduleFrameConfig extends FrameConfig { - private String heading; - private List events; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java deleted file mode 100644 index 5f5c962..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/EventListFrameConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import lombok.*; -import java.util.List; - -@Data @Builder @NoArgsConstructor @AllArgsConstructor -@EqualsAndHashCode(callSuper = true) -public class EventListFrameConfig extends FrameConfig { - private String heading; - private List events; -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java index 867a880..1c01542 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java @@ -7,9 +7,11 @@ * Base class for frame-specific configuration. * Uses Jackson annotations for polymorphic serialization. *

- * {@link FrameType#NEXT_PRAYER} deliberately has no subtype here: the backend holds no - * prayer-time source, so the board computes that frame itself from - * {@code deviceConfig.location}. + * {@link FrameType#NEXT_PRAYER} and {@link FrameType#INSTAGRAM} use empty marker configs + * ({@link NextPrayerFrameConfig}, {@link InstagramFrameConfig}) — the backend has no data to + * contribute to either (prayer times and the QR destination are both resolved client-side), but + * the frame still needs a real position and {@code durationInSeconds} in the sequence rather + * than being synthesized outside the API contract. */ @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -18,10 +20,11 @@ ) @JsonSubTypes({ @JsonSubTypes.Type(value = PosterFrameConfig.class, name = "poster"), - @JsonSubTypes.Type(value = EventListFrameConfig.class, name = "event_list"), - @JsonSubTypes.Type(value = DailyScheduleFrameConfig.class, name = "daily_schedule"), @JsonSubTypes.Type(value = JummahFrameConfig.class, name = "jummah"), - @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote") + @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote"), + @JsonSubTypes.Type(value = NextPrayerFrameConfig.class, name = "next_prayer"), + @JsonSubTypes.Type(value = AgendaFrameConfig.class, name = "agenda"), + @JsonSubTypes.Type(value = InstagramFrameConfig.class, name = "instagram") }) public abstract class FrameConfig { diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java index 60a03e8..3b2230b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameDefinition.java @@ -11,6 +11,18 @@ @AllArgsConstructor public class FrameDefinition { + /** + * Stable identity for this frame, independent of its position in the response. + * Entity-backed, multi-instance types use {@code ":"} (e.g. + * {@code "poster:3fa8..."}); singleton types (there is only ever one per assembly, e.g. + * {@code daily_schedule} or {@code agenda}) use a fixed slug equal to the type name. + *

+ * This is what a future per-frame ordering override will reference, so it must stay + * stable across payload rebuilds even though {@code FrameDefinition} itself is + * reconstructed from scratch on every request. + */ + private String frameId; + /** * The type of the frame. */ @@ -27,14 +39,4 @@ public class FrameDefinition { * This will likely be some subclass of FrameConfig. */ private FrameConfig frameConfig; - - /** - * Grouping for the frontend layout. - */ - private FrameSlot slot; - - /** - * Higher = shown first within a slot; null = neutral. - */ - private Integer priority; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java deleted file mode 100644 index 69ee4ea..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameSlot.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -public enum FrameSlot { - PRIMARY, // main carousel area - TICKER, // bottom scroller / secondary - SIDEBAR, // persistent side panel - OVERLAY // modals / interruptions (e.g. next prayer countdown) -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java index 1b3ae02..538ee54 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java @@ -19,9 +19,9 @@ */ public enum FrameType { POSTER("poster"), - EVENT_LIST("event_list"), - DAILY_SCHEDULE("daily_schedule"), NEXT_PRAYER("next_prayer"), + AGENDA("agenda"), + INSTAGRAM("instagram"), JUMMAH("jummah"), ISLAMIC_QUOTE("islamic_quote"); diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java new file mode 100644 index 0000000..c03aa70 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Marker config for {@link FrameType#INSTAGRAM}. Carries no fields: the QR destination is + * read client-side from {@code deviceConfig.socialUrl}, with its own app-level fallback when + * that is unset. This class exists only so the frame has a real position and + * {@code durationInSeconds} in the sequence, instead of being synthesized client-side. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InstagramFrameConfig extends FrameConfig { +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java new file mode 100644 index 0000000..e3cbe41 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/NextPrayerFrameConfig.java @@ -0,0 +1,15 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Marker config for {@link FrameType#NEXT_PRAYER}. Carries no fields: the backend holds no + * prayer-time source, so the board computes the countdown itself from + * {@code deviceConfig.location}. This class exists only so the frame has a real position and + * {@code durationInSeconds} in the sequence, instead of being synthesized client-side. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class NextPrayerFrameConfig extends FrameConfig { +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 25cdd12..7b26362 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -76,12 +76,33 @@ public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest re String socialUrl = request.getSocialUrl().trim(); existing.setSocialUrl(socialUrl.isEmpty() ? null : socialUrl); } + applyAgendaDuration(existing, request.getAgendaDurationSeconds()); + Patch.apply(request.getNextPrayerDurationSeconds(), existing::setNextPrayerDurationSeconds); DeviceConfig saved = boardConfigRepository.save(existing); log.info("Updated board config for device: {}", deviceId); boardStream.configChanged(deviceId); return saved; } + /** + * Stores the agenda duration, translating the {@code 0} sentinel back into null ("auto"). + *

+ * The 1–4 second gap is rejected here rather than on the DTO: expressing "zero or at least + * five" in bean validation needs an {@code @AssertTrue} method, which Jackson and springdoc + * would both surface as a phantom boolean property on the request schema. + */ + private void applyAgendaDuration(DeviceConfig existing, Integer requested) { + if (requested == null) return; // omitted — leave whatever is stored + if (requested != 0 && requested < DeviceConfig.MIN_SLIDE_SECONDS) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("agendaDurationSeconds must be 0 (auto) or between " + + DeviceConfig.MIN_SLIDE_SECONDS + " and " + + DeviceConfig.MAX_SLIDE_SECONDS + " seconds")); + } + existing.setAgendaDurationSeconds(requested == 0 ? null : requested); + } + public DeviceConfig updateTicker(UUID deviceId, UpdateTickerRequest request) { DeviceConfig existing = getBoardConfigOrThrow(deviceId); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); @@ -256,7 +277,7 @@ public Device updateDevice(UUID deviceId, UpdateDeviceRequest request) { .orElseThrow(() -> new ApiResponseException( HttpStatus.NOT_FOUND, ErrorResponse.of("Device not found: " + deviceId))); - Patch.apply(request.getDisplayName(), existing::setDisplayName); + Patch.apply(request.getDisplayName(), name -> existing.setDisplayName(name.trim())); Patch.apply(request.getAudience(), existing::setAudience); log.info("Updated device: id={}", deviceId); return deviceRepository.save(existing); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index 36a21bf..4f9bbcc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -8,8 +8,6 @@ import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; import java.time.Instant; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; import com.ibrasoft.lensbridge.util.Patch; import com.ibrasoft.lensbridge.repository.sql.PosterRepository; import lombok.RequiredArgsConstructor; @@ -22,7 +20,6 @@ import java.io.IOException; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -31,7 +28,6 @@ public class PosterService { private final PosterRepository posterRepository; private final R2StorageService r2StorageService; - private final PosterFrameTransformer posterFrameTransformer; private final BoardStreamHandler boardStream; @Value("${cloudflare.r2.public-url}") @@ -214,28 +210,6 @@ public void deletePoster(UUID posterId) { boardStream.contentChanged("posters"); } - // ==================== Musallah Board Methods ==================== - - /** - * Get active posters as FrameDefinitions for display on the musallah board. - * Returns only posters that are currently active and match the board's audience. - * Sorted by startDate descending (newest first). - */ - public List getActivePosterFrameDefinitions(Audience audience) { - return posterRepository.findActivePostersForAudienceAt(Instant.now(), audience) - .stream() - .map(this::toFrameDefinition) - .collect(Collectors.toList()); - } - - /** - * Convert a Poster to a FrameDefinition for the musallah board. - * Delegates to PosterFrameTransformer — single source of truth. - */ - private com.ibrasoft.lensbridge.model.board.frames.FrameDefinition toFrameDefinition(Poster poster) { - return posterFrameTransformer.transform(poster, null); - } - // ==================== Helper Methods ==================== private String generatePosterFilename(String originalFilename) { diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index 5b1912a..aac8361 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -3,19 +3,11 @@ import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; -import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.Device; -import com.ibrasoft.lensbridge.model.board.Poster; -import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.OpenWeatherService; -import com.ibrasoft.lensbridge.service.PosterService; -import com.ibrasoft.lensbridge.service.board.transformer.DailyScheduleFrameTransformer; -import com.ibrasoft.lensbridge.service.board.transformer.EventListFrameTransformer; -import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; -import com.ibrasoft.lensbridge.service.board.transformer.WeeklyContentFrameTransformer; +import com.ibrasoft.lensbridge.service.board.producer.FrameProducer; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,24 +15,24 @@ import org.springframework.stereotype.Service; import java.time.ZoneId; -import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @Slf4j @AllArgsConstructor public class BoardPayloadAssembler { - private final BoardService boardService; - private final PosterService posterService; private final DeviceRepository deviceRepository; - private final PosterFrameTransformer posterTransformer; - private final EventListFrameTransformer eventListTransformer; - private final DailyScheduleFrameTransformer dailyScheduleTransformer; - private final WeeklyContentFrameTransformer weeklyContentTransformer; private final OpenWeatherService openWeatherService; + /** + * Every registered {@link FrameProducer}, Spring-ordered via {@code @Order} on each + * implementation. + */ + private final List frameProducers; + private final ZoneId defaultZone; public MusallahBoardPayload assemble(UUID deviceId) { @@ -51,11 +43,9 @@ public MusallahBoardPayload assemble(UUID deviceId) { BoardContext ctx = BoardContext.of(device, defaultZone); - List frames = new ArrayList<>(); - frames.addAll(posterFrames(ctx)); - frames.addAll(dailyScheduleFrames(ctx)); - frames.addAll(eventFrames(ctx)); - frames.addAll(weeklyContentFrames(ctx)); + List frames = frameProducers.stream() + .flatMap(producer -> producer.produce(ctx).stream()) + .collect(Collectors.toList()); return MusallahBoardPayload.builder() .deviceConfig(ctx.getConfig()) @@ -63,30 +53,4 @@ public MusallahBoardPayload assemble(UUID deviceId) { .weather(openWeatherService.getCurrentWeather()) .build(); } - - private List posterFrames(BoardContext ctx) { - List posters = posterService.getActivePosterFramesForAudience(ctx.getDevice().getAudience()); - List out = new ArrayList<>(posters.size()); - for (Poster p : posters) out.add(posterTransformer.transform(p, ctx)); - return out; - } - - private List dailyScheduleFrames(BoardContext ctx) { - List todaysEvents = boardService.getEventsForAudienceInRange( - ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.currentDayEnd()); - if (todaysEvents.isEmpty()) return List.of(); - return List.of(dailyScheduleTransformer.transform(todaysEvents, ctx)); - } - - private List eventFrames(BoardContext ctx) { - List boardEvents = boardService.getEventsForAudienceInRange( - ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.rollingWindowEnd()); - if (boardEvents.isEmpty()) return List.of(); - return List.of(eventListTransformer.transform(boardEvents, ctx)); - } - - private List weeklyContentFrames(BoardContext ctx) { - WeeklyContent content = boardService.getWeeklyContentFor(ctx.today()).orElse(null); - return weeklyContentTransformer.transform(content, ctx); - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java new file mode 100644 index 0000000..8760e5b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java @@ -0,0 +1,114 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.dto.board.response.frames.DayBucket; +import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.frames.AgendaFrameConfig; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Emits the single AGENDA frame: every day from today to the end of the rolling window, each + * carrying the events that fall on it in the device's timezone. + *

+ * This replaced the {@code event_list} and {@code daily_schedule} producers. Both ran the same + * query over different windows and differed only in how the frontend drew the result, so they + * were one frame with two layouts rather than two frames. Today's events are the first bucket. + */ +@Component +@RequiredArgsConstructor +@Order(2) +public class AgendaFrameProducer implements FrameProducer { + + private final BoardService boardService; + + @Override + public List produce(BoardContext ctx) { + List events = boardService.getEventsForAudienceInRange( + ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.rollingWindowEnd()); + + // An agenda reading "nothing scheduled" for seven straight days is worse than no agenda + // at all. Both frames this replaced skipped themselves when empty; so does this one. + if (events.isEmpty()) return List.of(); + + return List.of(transform(events, ctx)); + } + + public FrameDefinition transform(List events, BoardContext ctx) { + AgendaFrameConfig config = AgendaFrameConfig.builder() + .heading("Next 7 Days") + .days(bucketByDay(events, ctx)) + .build(); + + return FrameDefinition.builder() + .frameId("agenda") + .frameType(FrameType.AGENDA) + .durationInSeconds(configuredDuration(ctx)) + .frameConfig(config) + .build(); + } + + /** + * Null means "auto": the board grows the dwell time with the number of events it ends up + * rendering. Only the board can decide that — it knows which day is current and how many + * rows survived the queue limit, and both change while a single payload is on screen. + */ + private Integer configuredDuration(BoardContext ctx) { + return ctx == null || ctx.getConfig() == null + ? null + : ctx.getConfig().getAgendaDurationSeconds(); + } + + /** + * Buckets by local date, emitting every day in the window whether or not it has events. + * The last day is derived from {@link BoardContext#rollingWindowEnd()} rather than hardcoded, + * so widening the window here needs no change. + *

+ * A multi-day event appears in every bucket it overlaps, not only the one it starts in — a + * conference running Monday to Wednesday should show up on Tuesday's agenda. + */ + private List bucketByDay(List events, BoardContext ctx) { + LocalDate firstDay = ctx.today(); + LocalDate lastDay = ctx.rollingWindowEnd().atZone(ctx.zone()).toLocalDate(); + + List buckets = new ArrayList<>(); + for (LocalDate day = firstDay; !day.isAfter(lastDay); day = day.plusDays(1)) { + Instant dayStart = day.atStartOfDay(ctx.zone()).toInstant(); + Instant dayEnd = day.plusDays(1).atStartOfDay(ctx.zone()).toInstant(); + + List onThisDay = events.stream() + .filter(e -> overlaps(e, dayStart, dayEnd)) + .sorted(Comparator.comparing(BoardEvent::getStartTime)) + .map(EventView::of) + .toList(); + + buckets.add(DayBucket.builder().date(day).events(onThisDay).build()); + } + return buckets; + } + + /** + * Half-open overlap: an event occupies {@code [start, end)} and the day is + * {@code [dayStart, dayEnd)}, so an event ending exactly at midnight belongs to the day it + * ended, not the next one. + *

+ * A zero-length event would overlap nothing under that rule, so it is treated as an instant + * on its start day. Same for the malformed {@code end < start} case. + */ + private boolean overlaps(BoardEvent event, Instant dayStart, Instant dayEnd) { + Instant start = event.getStartTime(); + Instant end = event.getEndTime().isAfter(start) ? event.getEndTime() : start.plusNanos(1); + return start.isBefore(dayEnd) && end.isAfter(dayStart); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java new file mode 100644 index 0000000..3d79b88 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/FrameProducer.java @@ -0,0 +1,17 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.service.board.BoardContext; + +import java.util.List; + +/** + * Fetches its own source data for one board and turns it into zero or more frames. + *

+ * {@link com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler} only knows this + * interface — it never references a concrete frame type. Adding a new frame type means + * adding one new {@code @Component} that implements this; the assembler does not change. + */ +public interface FrameProducer { + List produce(BoardContext ctx); +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java new file mode 100644 index 0000000..84c03e8 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java @@ -0,0 +1,32 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.InstagramFrameConfig; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Emits the INSTAGRAM frame. It carries no data — the QR destination is read client-side + * from {@code deviceConfig.socialUrl}, which already has its own app-level fallback when + * unset — but declaring it here gives it a real {@code durationInSeconds} and a place in the + * ordered sequence instead of being synthesized and hardcoded in the frontend. Unlike the + * other frame types, this one is not conditional on any backend data: it is always shown. + */ +@Component +@Order(7) +public class InstagramFrameProducer implements FrameProducer { + + @Override + public List produce(BoardContext ctx) { + return List.of(FrameDefinition.builder() + .frameId("instagram") + .frameType(FrameType.INSTAGRAM) + .durationInSeconds(13) + .frameConfig(new InstagramFrameConfig()) + .build()); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java new file mode 100644 index 0000000..6d2195b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java @@ -0,0 +1,43 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.NextPrayerFrameConfig; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Emits the NEXT_PRAYER frame. It carries no data — the countdown is computed client-side + * from {@code deviceConfig.location} — but declaring it here gives it a real + * {@code durationInSeconds} and a place in the ordered sequence instead of being synthesized + * and hardcoded in the frontend. + */ +@Component +@Order(1) +public class NextPrayerFrameProducer implements FrameProducer { + + @Override + public List produce(BoardContext ctx) { + return List.of(FrameDefinition.builder() + .frameId("next_prayer") + .frameType(FrameType.NEXT_PRAYER) + .durationInSeconds(configuredDuration(ctx)) + .frameConfig(new NextPrayerFrameConfig()) + .build()); + } + + /** + * Never null, unlike the agenda's: this slide renders a countdown whose size does not + * change with the data, so there is nothing for an "auto" mode to measure. A device with + * no config at all still gets the default rather than handing the board a null to guess at. + */ + private int configuredDuration(BoardContext ctx) { + return ctx == null || ctx.getConfig() == null + ? DeviceConfig.DEFAULT_NEXT_PRAYER_DURATION_SECONDS + : ctx.getConfig().getNextPrayerDurationSeconds(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java similarity index 55% rename from src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java rename to src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java index 07d046f..c8b0929 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java @@ -1,22 +1,32 @@ -package com.ibrasoft.lensbridge.service.board.transformer; +package com.ibrasoft.lensbridge.service.board.producer; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.model.board.frames.PosterFrameConfig; +import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.stream.Collectors; + @Component -public class PosterFrameTransformer implements FrameTransformer { +@RequiredArgsConstructor +@Order(3) +public class PosterFrameProducer implements FrameProducer { + + private final PosterService posterService; @Override - public FrameType supports() { - return FrameType.POSTER; + public List produce(BoardContext ctx) { + List posters = + posterService.getActivePosterFramesForAudience(ctx.getDevice().getAudience()); + return posters.stream().map(p -> transform(p, ctx)).collect(Collectors.toList()); } - @Override public FrameDefinition transform(Poster poster, BoardContext ctx) { PosterFrameConfig config = PosterFrameConfig.builder() .posterUrl(poster.getImage()) @@ -25,11 +35,10 @@ public FrameDefinition transform(Poster poster, BoardContext ctx) { .build(); return FrameDefinition.builder() + .frameId("poster:" + poster.getId()) .frameType(FrameType.POSTER) .durationInSeconds(poster.getDuration()) .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java similarity index 74% rename from src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java rename to src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java index d903bea..411ac3a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java @@ -1,14 +1,16 @@ -package com.ibrasoft.lensbridge.service.board.transformer; +package com.ibrasoft.lensbridge.service.board.producer; import com.ibrasoft.lensbridge.model.board.IslamicQuote; import com.ibrasoft.lensbridge.model.board.JummahPrayer; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.model.board.frames.IslamicQuoteFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.JummahFrameConfig; +import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.util.ArrayList; @@ -19,11 +21,25 @@ * Transforms a WeeklyContent entity into FrameDefinitions: * one per quote (VERSE/HADITH kind) and one for jummah prayer info. * - * This transformer intentionally does NOT implement FrameTransformer because - * one source expands to multiple frames. The assembler injects it by concrete class. + * This intentionally does NOT implement FrameTransformer because one source expands + * to multiple frames — but it does implement {@link FrameProducer}, since that interface + * already returns a List. */ @Component -public class WeeklyContentFrameTransformer { +@RequiredArgsConstructor +@Order(6) +public class WeeklyContentFrameProducer implements FrameProducer { + + private final BoardService boardService; + + private final static String JUMUAH_FRAME_ID = "jumuah"; + private final static String QUOTE_FRAME_ID = "quote:"; + + @Override + public List produce(BoardContext ctx) { + WeeklyContent content = boardService.getWeeklyContentFor(ctx.today()).orElse(null); + return transform(content, ctx); + } public List transform(WeeklyContent content, BoardContext ctx) { List out = new ArrayList<>(); @@ -53,11 +69,10 @@ private FrameDefinition quoteFrame(IslamicQuote quote, IslamicQuoteFrameConfig.K .build(); return FrameDefinition.builder() + .frameId(QUOTE_FRAME_ID + quote.getId()) .frameType(FrameType.ISLAMIC_QUOTE) .durationInSeconds(null) .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) .build(); } @@ -75,11 +90,10 @@ private FrameDefinition jummahFrame(List prayers) { .build(); return FrameDefinition.builder() + .frameId(JUMUAH_FRAME_ID) .frameType(FrameType.JUMMAH) .durationInSeconds(null) .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java deleted file mode 100644 index 671f320..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformer.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import com.ibrasoft.lensbridge.model.board.BoardEvent; -import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * Transforms a list of Events into a single DAILY_SCHEDULE FrameDefinition - * representing events for the current day. - */ -@Component -public class DailyScheduleFrameTransformer implements FrameTransformer> { - - @Override - public FrameType supports() { - return FrameType.DAILY_SCHEDULE; - } - - @Override - public FrameDefinition transform(List boardEvents, BoardContext ctx) { - List eventViews = boardEvents.stream() - .map(EventView::of) - .collect(Collectors.toList()); - - DailyScheduleFrameConfig config = DailyScheduleFrameConfig.builder() - .heading("Today") - .events(eventViews) - .build(); - - return FrameDefinition.builder() - .frameType(FrameType.DAILY_SCHEDULE) - .durationInSeconds(null) - .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) - .build(); - } - -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java deleted file mode 100644 index c7ef72a..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformer.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import com.ibrasoft.lensbridge.model.board.BoardEvent; -import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -/** - * Transforms a list of Events into a single EVENT_LIST FrameDefinition representing the board's - * rolling agenda window (today and the next six days - * See {@link com.ibrasoft.lensbridge.service.board.BoardContext#rollingWindowEnd()}. - */ -@Component -public class EventListFrameTransformer implements FrameTransformer> { - - @Override - public FrameType supports() { - return FrameType.EVENT_LIST; - } - - @Override - public FrameDefinition transform(List boardEvents, BoardContext ctx) { - List eventViews = boardEvents.stream() - .map(EventView::of) - .collect(Collectors.toList()); - - EventListFrameConfig config = EventListFrameConfig.builder() - .heading("Next 7 Days") - .events(eventViews) - .build(); - - return FrameDefinition.builder() - .frameType(FrameType.EVENT_LIST) - .durationInSeconds(null) - .frameConfig(config) - .slot(FrameSlot.PRIMARY) - .priority(null) - .build(); - } - -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java deleted file mode 100644 index a7abd50..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/transformer/FrameTransformer.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.service.board.BoardContext; - -public interface FrameTransformer { - FrameType supports(); - FrameDefinition transform(T source, BoardContext ctx); -} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java new file mode 100644 index 0000000..3180cda --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java @@ -0,0 +1,122 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.auth.Role; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.service.AdminAuditService; +import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Request binding and validation for PATCH /api/admin/board/devices/{deviceId}. + *

+ * The endpoint carried {@code @Valid} from the start, but {@link UpdateDeviceRequest} + * declared no constraints, so it validated nothing: a blank {@code displayName} reached + * {@code Device.displayName}, whose column is {@code nullable = false} but says nothing + * about emptiness. The board then showed as unnamed in every admin listing. These tests + * pin both halves — the constraints, and the {@code @Valid} that runs them. + */ +@WebMvcTest(controllers = BoardAdminController.class) +@Import(MethodSecurityTestConfig.class) +class BoardAdminControllerUpdateDeviceTest { + + private static final String URL = "/api/admin/board/devices/"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private BoardService boardService; + @MockitoBean + private PosterService posterService; + @MockitoBean + private AdminAuditService auditService; + @MockitoBean + private BoardStreamHandler boardStreamHandler; + + /** WebConfig registers CurrentUserArgumentResolver, which needs this in the slice. */ + @MockitoBean + private UserService userService; + + private static final UUID DEVICE_ID = UUID.randomUUID(); + + private void patchBody(String json, int expectedStatus) throws Exception { + mockMvc.perform(patch(URL + DEVICE_ID) + .with(TestAuthorities.as(Role.BOARD_ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().is(expectedStatus)); + } + + @Test + void rejectsAnEmptyDisplayName() throws Exception { + mockMvc.perform(patch(URL + DEVICE_ID) + .with(TestAuthorities.as(Role.BOARD_ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"displayName\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value( + org.hamcrest.Matchers.containsString("displayName"))); + + verify(boardService, never()).updateDevice(any(), any()); + } + + /** @Size(min=1) would have let this through; the blank check is what catches it. */ + @Test + void rejectsAWhitespaceOnlyDisplayName() throws Exception { + patchBody("{\"displayName\":\" \"}", 400); + + verify(boardService, never()).updateDevice(any(), any()); + } + + @Test + void rejectsADisplayNameLongerThanTheColumn() throws Exception { + patchBody("{\"displayName\":\"" + "x".repeat(256) + "\"}", 400); + + verify(boardService, never()).updateDevice(any(), any()); + } + + /** Null means "leave it alone" on a PATCH, so an audience-only body must pass. */ + @Test + void acceptsAnOmittedDisplayName() throws Exception { + when(boardService.updateDevice(eq(DEVICE_ID), any())) + .thenReturn(Device.builder().id(DEVICE_ID).displayName("Musallah A") + .audience(Audience.BOTH).build()); + + patchBody("{\"audience\":\"both\"}", 200); + + verify(boardService).updateDevice(eq(DEVICE_ID), any()); + } + + @Test + void acceptsARealDisplayName() throws Exception { + when(boardService.updateDevice(eq(DEVICE_ID), any())) + .thenReturn(Device.builder().id(DEVICE_ID).displayName("Musallah A") + .audience(Audience.BOTH).build()); + + patchBody("{\"displayName\":\"Musallah A\"}", 200); + + verify(boardService).updateDevice(eq(DEVICE_ID), any()); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..05f60f3 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,115 @@ +package com.ibrasoft.lensbridge.exception; + +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Spring's own request-handling failures must keep their status codes. + *

+ * These used to be swallowed by the catch-all {@code @ExceptionHandler(Exception.class)}, + * which {@code ExceptionHandlerExceptionResolver} reaches before Spring's + * {@code DefaultHandlerExceptionResolver} gets a chance to assign the right one. A client + * that posted a JSON body without a {@code Content-Type} header therefore got a 500 + * reading "An unexpected error occurred", which pointed the investigation at the server. + */ +class GlobalExceptionHandlerTest { + + private static final String URL = "/test/echo"; + + private MockMvc mockMvc; + + @Data + static class Payload { + @NotBlank + private String name; + } + + @RestController + static class StubController { + @PostMapping(URL) + MessageResponse echo(@Valid @RequestBody Payload payload) { + return new MessageResponse(payload.getName()); + } + } + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders.standaloneSetup(new StubController()) + .setValidator(validator) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + /** + * The production incident: Firefox sent the signup body with no Content-Type, Spring + * substituted application/octet-stream, and the client saw a 500. + */ + @Test + void aBodyWithNoContentTypeIsA415ThatNamesTheMissingHeader() throws Exception { + mockMvc.perform(post(URL).content("{\"name\":\"T\"}")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.message", containsString("Content-Type"))); + } + + @Test + void anUnsupportedContentTypeIsA415ThatNamesTheAcceptedTypes() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.TEXT_PLAIN) + .content("{\"name\":\"T\"}")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.message", containsString("application/json"))); + } + + /** + * The Allow header is what tells a client the path exists and only the method was + * wrong. Overriding the parent handler to swap the body means setting it here, since + * the parent's own implementation — which does set it — no longer runs. + */ + @Test + void theWrongMethodIsA405ThatAdvertisesTheAllowedOnes() throws Exception { + mockMvc.perform(get(URL)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().string("Allow", containsString("POST"))) + .andExpect(jsonPath("$.message", containsString("POST"))); + } + + @Test + void unparseableJsonIsA400() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{not json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Malformed JSON request")); + } + + /** Field errors keep the "field: message" formatting the frontend surfaces verbatim. */ + @Test + void aFailedConstraintIsA400NamingTheField() throws Exception { + mockMvc.perform(post(URL) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", containsString("name:"))); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java index ec84586..84f5fc2 100644 --- a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeJsonTest.java @@ -27,8 +27,7 @@ class FrameTypeJsonTest { @Test void serialisesAsLowercase() throws Exception { assertEquals("\"poster\"", mapper.writeValueAsString(FrameType.POSTER)); - assertEquals("\"event_list\"", mapper.writeValueAsString(FrameType.EVENT_LIST)); - assertEquals("\"daily_schedule\"", mapper.writeValueAsString(FrameType.DAILY_SCHEDULE)); + assertEquals("\"agenda\"", mapper.writeValueAsString(FrameType.AGENDA)); assertEquals("\"next_prayer\"", mapper.writeValueAsString(FrameType.NEXT_PRAYER)); assertEquals("\"jummah\"", mapper.writeValueAsString(FrameType.JUMMAH)); assertEquals("\"islamic_quote\"", mapper.writeValueAsString(FrameType.ISLAMIC_QUOTE)); @@ -37,7 +36,7 @@ void serialisesAsLowercase() throws Exception { @Test void readsTheDocumentedLowercaseForm() throws Exception { assertEquals(FrameType.POSTER, mapper.readValue("\"poster\"", FrameType.class)); - assertEquals(FrameType.EVENT_LIST, mapper.readValue("\"event_list\"", FrameType.class)); + assertEquals(FrameType.ISLAMIC_QUOTE, mapper.readValue("\"islamic_quote\"", FrameType.class)); } /** Consumers written against the previous uppercase behaviour must keep working. */ diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java index 24f7074..0334049 100644 --- a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameTypeTest.java @@ -9,8 +9,7 @@ class FrameTypeTest { @Test void toStringReturnsSnakeCaseTypeName() { assertThat(FrameType.POSTER.toString()).isEqualTo("poster"); - assertThat(FrameType.EVENT_LIST.toString()).isEqualTo("event_list"); - assertThat(FrameType.DAILY_SCHEDULE.toString()).isEqualTo("daily_schedule"); + assertThat(FrameType.AGENDA.toString()).isEqualTo("agenda"); assertThat(FrameType.NEXT_PRAYER.toString()).isEqualTo("next_prayer"); assertThat(FrameType.JUMMAH.toString()).isEqualTo("jummah"); assertThat(FrameType.ISLAMIC_QUOTE.toString()).isEqualTo("islamic_quote"); @@ -18,6 +17,6 @@ void toStringReturnsSnakeCaseTypeName() { @Test void toStringDiffersFromEnumNameForMultiWordTypes() { - assertThat(FrameType.EVENT_LIST.toString()).isNotEqualTo(FrameType.EVENT_LIST.name()); + assertThat(FrameType.NEXT_PRAYER.toString()).isNotEqualTo(FrameType.NEXT_PRAYER.name()); } } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java index f3b04bd..10c6140 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java @@ -3,6 +3,7 @@ import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; import com.ibrasoft.lensbridge.dto.board.request.WeeklyContentRequest; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.handler.BoardStreamHandler; @@ -386,4 +387,129 @@ void updateBoardConfigCapturesPatchedLocation() { verify(boardConfigRepository).save(captor.capture()); assertThat(captor.getValue().getLocation()).isSameAs(loc); } + + // ==================== slide durations ==================== + + private DeviceConfig configForDurationTest(UUID id) { + DeviceConfig existing = config(id); + when(boardConfigRepository.findById(id)).thenReturn(Optional.of(existing)); + when(boardConfigRepository.save(any(DeviceConfig.class))) + .thenAnswer(inv -> inv.getArgument(0)); + return existing; + } + + @Test + void updateBoardConfigPinsTheAgendaDuration() { + UUID id = UUID.randomUUID(); + configForDurationTest(id); + + DeviceConfig saved = service.updateBoardConfig( + id, UpdateBoardConfigRequest.builder().agendaDurationSeconds(35).build()); + + assertThat(saved.getAgendaDurationSeconds()).isEqualTo(35); + } + + /** Zero is the only way to say "clear it" — Patch skips null, so null means "leave it". */ + @Test + void updateBoardConfigTreatsZeroAgendaDurationAsAuto() { + UUID id = UUID.randomUUID(); + DeviceConfig existing = configForDurationTest(id); + existing.setAgendaDurationSeconds(35); + + DeviceConfig saved = service.updateBoardConfig( + id, UpdateBoardConfigRequest.builder().agendaDurationSeconds(0).build()); + + assertThat(saved.getAgendaDurationSeconds()).isNull(); + } + + @Test + void updateBoardConfigLeavesAgendaDurationAloneWhenOmitted() { + UUID id = UUID.randomUUID(); + DeviceConfig existing = configForDurationTest(id); + existing.setAgendaDurationSeconds(35); + + DeviceConfig saved = service.updateBoardConfig( + id, UpdateBoardConfigRequest.builder().darkModeAfterIsha(true).build()); + + assertThat(saved.getAgendaDurationSeconds()).isEqualTo(35); + } + + /** The 1–4 gap the DTO's @Min(0) cannot express. */ + @Test + void updateBoardConfigRejectsAnAgendaDurationBetweenZeroAndTheMinimum() { + UUID id = UUID.randomUUID(); + when(boardConfigRepository.findById(id)).thenReturn(Optional.of(config(id))); + + assertThatThrownBy(() -> service.updateBoardConfig( + id, UpdateBoardConfigRequest.builder().agendaDurationSeconds(3).build())) + .isInstanceOf(ApiResponseException.class); + + verify(boardConfigRepository, never()).save(any(DeviceConfig.class)); + } + + @Test + void updateBoardConfigPinsTheNextPrayerDuration() { + UUID id = UUID.randomUUID(); + configForDurationTest(id); + + DeviceConfig saved = service.updateBoardConfig( + id, UpdateBoardConfigRequest.builder().nextPrayerDurationSeconds(25).build()); + + assertThat(saved.getNextPrayerDurationSeconds()).isEqualTo(25); + } + + /** Unset means the default, never null — the payload always carries a concrete number. */ + @Test + void nextPrayerDurationDefaultsWhenNothingIsStored() { + assertThat(DeviceConfig.builder().build().getNextPrayerDurationSeconds()) + .isEqualTo(DeviceConfig.DEFAULT_NEXT_PRAYER_DURATION_SECONDS); + } + + // ==================== updateDevice ==================== + + /** + * Validation rejects a name that is only whitespace; this covers the padded + * one it lets through. " Musallah A " and "Musallah A" are the same board to anyone + * reading the admin list, and only one of them sorts where you expect. + */ + @Test + void updateDeviceTrimsTheDisplayName() { + UUID id = UUID.randomUUID(); + Device existing = Device.builder().id(id).displayName("Old").audience(Audience.BOTH).build(); + when(deviceRepository.findById(id)).thenReturn(Optional.of(existing)); + when(deviceRepository.save(any(Device.class))).thenAnswer(inv -> inv.getArgument(0)); + + Device updated = service.updateDevice( + id, UpdateDeviceRequest.builder().displayName(" Musallah A ").build()); + + assertThat(updated.getDisplayName()).isEqualTo("Musallah A"); + } + + /** A PATCH omitting a field must leave it alone rather than nulling it. */ + @Test + void updateDeviceLeavesOmittedFieldsUntouched() { + UUID id = UUID.randomUUID(); + Device existing = Device.builder().id(id).displayName("Musallah A") + .audience(Audience.BROTHERS).build(); + when(deviceRepository.findById(id)).thenReturn(Optional.of(existing)); + when(deviceRepository.save(any(Device.class))).thenAnswer(inv -> inv.getArgument(0)); + + Device updated = service.updateDevice( + id, UpdateDeviceRequest.builder().audience(Audience.SISTERS).build()); + + assertThat(updated.getDisplayName()).isEqualTo("Musallah A"); + assertThat(updated.getAudience()).isEqualTo(Audience.SISTERS); + } + + @Test + void updateDeviceRejectsAnUnknownDevice() { + UUID id = UUID.randomUUID(); + when(deviceRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateDevice( + id, UpdateDeviceRequest.builder().displayName("X").build())) + .isInstanceOf(ApiResponseException.class); + + verify(deviceRepository, never()).save(any(Device.class)); + } } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java index da6f10f..af160b1 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java @@ -6,10 +6,7 @@ import com.ibrasoft.lensbridge.handler.BoardStreamHandler; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.repository.sql.PosterRepository; -import com.ibrasoft.lensbridge.service.board.transformer.PosterFrameTransformer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -44,8 +41,6 @@ class PosterServiceTest { @Mock private R2StorageService r2StorageService; - @Mock - private PosterFrameTransformer posterFrameTransformer; @Mock private BoardStreamHandler boardStream; @@ -127,19 +122,6 @@ void getActivePostersQueriesActiveAtNow() { assertThat(service.getActivePosters()).isEqualTo(expected); } - @Test - void getActivePosterFrameDefinitionsMapsThroughTransformer() { - Poster p = poster("a"); - FrameDefinition frame = FrameDefinition.builder().frameType(FrameType.POSTER).build(); - when(posterRepository.findActivePostersForAudienceAt(any(Instant.class), any(Audience.class))) - .thenReturn(List.of(p)); - when(posterFrameTransformer.transform(p, null)).thenReturn(frame); - - List result = service.getActivePosterFrameDefinitions(Audience.BOTH); - - assertThat(result).containsExactly(frame); - } - // ==================== createPoster ==================== @Test diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java new file mode 100644 index 0000000..0d31e19 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java @@ -0,0 +1,209 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.dto.board.response.frames.DayBucket; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardEvent; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.model.board.frames.AgendaFrameConfig; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.service.BoardService; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AgendaFrameProducerTest { + + private static final ZoneId TORONTO = ZoneId.of("America/Toronto"); + + @Mock + private BoardService boardService; + + @InjectMocks + private AgendaFrameProducer producer; + + /** Wednesday 2026-05-13, 10:00 local. */ + private BoardContext ctx() { + return BoardContext.builder() + .device(Device.builder().audience(Audience.BOTH).build()) + .now(ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, TORONTO)) + .build(); + } + + /** Local wall-clock times in Toronto, so tests read the way the board does. */ + private Instant local(String isoLocalDateTime) { + return java.time.LocalDateTime.parse(isoLocalDateTime).atZone(TORONTO).toInstant(); + } + + private BoardEvent event(String name, String start, String end) { + return BoardEvent.builder() + .name(name) + .startTime(local(start)) + .endTime(local(end)) + .allDay(false) + .audience(Audience.BOTH) + .build(); + } + + private AgendaFrameConfig configFrom(List events) { + when(boardService.getEventsForAudienceInRange(any(), any(), any())).thenReturn(events); + FrameDefinition def = producer.produce(ctx()).get(0); + return (AgendaFrameConfig) def.getFrameConfig(); + } + + private DayBucket bucketFor(AgendaFrameConfig config, String isoDate) { + return config.getDays().stream() + .filter(b -> b.getDate().equals(LocalDate.parse(isoDate))) + .findFirst() + .orElseThrow(() -> new AssertionError("no bucket for " + isoDate)); + } + + @Test + void producesOneAgendaFrameWithExpectedShell() { + when(boardService.getEventsForAudienceInRange(any(), any(), any())) + .thenReturn(List.of(event("A", "2026-05-13T18:00", "2026-05-13T19:00"))); + + List frames = producer.produce(ctx()); + + assertThat(frames).hasSize(1); + FrameDefinition def = frames.get(0); + assertThat(def.getFrameId()).isEqualTo("agenda"); + assertThat(def.getFrameType()).isEqualTo(FrameType.AGENDA); + assertThat(def.getFrameConfig()).isInstanceOf(AgendaFrameConfig.class); + } + + @Test + void queriesTheDeviceAudienceOverTheRollingWindow() { + when(boardService.getEventsForAudienceInRange( + eq(Audience.BOTH), + eq(local("2026-05-13T00:00")), + any())) + .thenReturn(List.of(event("A", "2026-05-13T18:00", "2026-05-13T19:00"))); + + assertThat(producer.produce(ctx())).hasSize(1); + } + + @Test + void emitsSevenConsecutiveDaysStartingToday() { + AgendaFrameConfig config = + configFrom(List.of(event("A", "2026-05-13T18:00", "2026-05-13T19:00"))); + + assertThat(config.getDays()).hasSize(7); + assertThat(config.getDays().get(0).getDate()).isEqualTo(LocalDate.of(2026, 5, 13)); + assertThat(config.getDays().get(6).getDate()).isEqualTo(LocalDate.of(2026, 5, 19)); + } + + @Test + void daysWithNoEventsStillGetAnEmptyBucket() { + AgendaFrameConfig config = + configFrom(List.of(event("A", "2026-05-13T18:00", "2026-05-13T19:00"))); + + assertThat(bucketFor(config, "2026-05-15").getEvents()).isEmpty(); + assertThat(bucketFor(config, "2026-05-13").getEvents()).hasSize(1); + } + + @Test + void multiDayEventAppearsInEveryDayItOverlaps() { + AgendaFrameConfig config = + configFrom(List.of(event("Conference", "2026-05-14T09:00", "2026-05-16T17:00"))); + + assertThat(bucketFor(config, "2026-05-13").getEvents()).isEmpty(); + assertThat(bucketFor(config, "2026-05-14").getEvents()).extracting("name") + .containsExactly("Conference"); + assertThat(bucketFor(config, "2026-05-15").getEvents()).extracting("name") + .containsExactly("Conference"); + assertThat(bucketFor(config, "2026-05-16").getEvents()).extracting("name") + .containsExactly("Conference"); + assertThat(bucketFor(config, "2026-05-17").getEvents()).isEmpty(); + } + + @Test + void eventEndingExactlyAtMidnightBelongsToTheDayItEnded() { + AgendaFrameConfig config = + configFrom(List.of(event("Late", "2026-05-14T22:00", "2026-05-15T00:00"))); + + assertThat(bucketFor(config, "2026-05-14").getEvents()).hasSize(1); + assertThat(bucketFor(config, "2026-05-15").getEvents()).isEmpty(); + } + + @Test + void zeroLengthEventLandsOnItsStartDay() { + AgendaFrameConfig config = + configFrom(List.of(event("Instant", "2026-05-14T13:00", "2026-05-14T13:00"))); + + assertThat(bucketFor(config, "2026-05-14").getEvents()).extracting("name") + .containsExactly("Instant"); + } + + @Test + void eventsWithinADayAreOrderedByStartTime() { + AgendaFrameConfig config = configFrom(List.of( + event("Evening", "2026-05-14T19:00", "2026-05-14T20:00"), + event("Morning", "2026-05-14T08:00", "2026-05-14T09:00"), + event("Noon", "2026-05-14T12:00", "2026-05-14T13:00"))); + + assertThat(bucketFor(config, "2026-05-14").getEvents()).extracting("name") + .containsExactly("Morning", "Noon", "Evening"); + } + + @Test + void bucketsUseTheDeviceTimezoneNotUtc() { + // 01:30 UTC on the 15th is still 21:30 on the 14th in Toronto. + AgendaFrameConfig config = configFrom(List.of(BoardEvent.builder() + .name("LateNight") + .startTime(Instant.parse("2026-05-15T01:30:00Z")) + .endTime(Instant.parse("2026-05-15T02:30:00Z")) + .allDay(false) + .audience(Audience.BOTH) + .build())); + + assertThat(bucketFor(config, "2026-05-14").getEvents()).hasSize(1); + assertThat(bucketFor(config, "2026-05-15").getEvents()).isEmpty(); + } + + /** null is the wire signal for "auto" — the board sizes the slide to what it renders. */ + @Test + void durationIsNullWhenTheDeviceHasNoAgendaDurationSet() { + when(boardService.getEventsForAudienceInRange(any(), any(), any())) + .thenReturn(List.of(event("A", "2026-05-13T18:00", "2026-05-13T19:00"))); + + assertThat(producer.produce(ctx()).get(0).getDurationInSeconds()).isNull(); + } + + @Test + void durationIsTheConfiguredValueWhenTheDevicePinsOne() { + when(boardService.getEventsForAudienceInRange(any(), any(), any())) + .thenReturn(List.of(event("A", "2026-05-13T18:00", "2026-05-13T19:00"))); + + BoardContext pinned = BoardContext.builder() + .device(Device.builder().audience(Audience.BOTH).build()) + .config(DeviceConfig.builder().agendaDurationSeconds(35).build()) + .now(ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, TORONTO)) + .build(); + + assertThat(producer.produce(pinned).get(0).getDurationInSeconds()).isEqualTo(35); + } + + @Test + void emitsNoFrameWhenTheWholeWindowIsEmpty() { + when(boardService.getEventsForAudienceInRange(any(), any(), any())).thenReturn(List.of()); + + assertThat(producer.produce(ctx())).isEmpty(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java new file mode 100644 index 0000000..5c34c89 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java @@ -0,0 +1,28 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.InstagramFrameConfig; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class InstagramFrameProducerTest { + + private final InstagramFrameProducer producer = new InstagramFrameProducer(); + + @Test + void produceReturnsOneMarkerFrameRegardlessOfContext() { + List frames = producer.produce(null); + + assertThat(frames).hasSize(1); + FrameDefinition def = frames.get(0); + assertThat(def.getFrameId()).isEqualTo("instagram"); + assertThat(def.getFrameType()).isEqualTo(FrameType.INSTAGRAM); + assertThat(def.getDurationInSeconds()).isEqualTo(13); + assertThat(def.getFrameConfig()).isInstanceOf(InstagramFrameConfig.class); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducerTest.java new file mode 100644 index 0000000..b3ce859 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducerTest.java @@ -0,0 +1,65 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.NextPrayerFrameConfig; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import com.ibrasoft.lensbridge.service.board.BoardContext; + +import org.junit.jupiter.api.Test; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class NextPrayerFrameProducerTest { + + private final NextPrayerFrameProducer producer = new NextPrayerFrameProducer(); + + @Test + void produceReturnsOneMarkerFrame() { + List frames = producer.produce(null); + + assertThat(frames).hasSize(1); + FrameDefinition def = frames.get(0); + assertThat(def.getFrameId()).isEqualTo("next_prayer"); + assertThat(def.getFrameType()).isEqualTo(FrameType.NEXT_PRAYER); + assertThat(def.getDurationInSeconds()).isEqualTo(12); + assertThat(def.getFrameConfig()).isInstanceOf(NextPrayerFrameConfig.class); + } + + private BoardContext ctxWith(DeviceConfig config) { + return BoardContext.builder() + .device(Device.builder().audience(Audience.BOTH).build()) + .config(config) + .now(ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneId.of("America/Toronto"))) + .build(); + } + + @Test + void usesTheDurationConfiguredForTheDevice() { + DeviceConfig config = DeviceConfig.builder().nextPrayerDurationSeconds(25).build(); + + assertThat(producer.produce(ctxWith(config)).get(0).getDurationInSeconds()).isEqualTo(25); + } + + /** A config row predating the column has null stored; the frame still needs a number. */ + @Test + void fallsBackToTheDefaultWhenNothingIsStored() { + DeviceConfig config = DeviceConfig.builder().build(); + + assertThat(producer.produce(ctxWith(config)).get(0).getDurationInSeconds()) + .isEqualTo(DeviceConfig.DEFAULT_NEXT_PRAYER_DURATION_SECONDS); + } + + /** Unlike the agenda, this frame must never hand the board a null to interpret. */ + @Test + void neverEmitsNull() { + assertThat(producer.produce(ctxWith(null)).get(0).getDurationInSeconds()).isNotNull(); + assertThat(producer.produce(null).get(0).getDurationInSeconds()).isNotNull(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java similarity index 79% rename from src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java rename to src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java index 33c20fd..f6390a0 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/PosterFrameTransformerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java @@ -1,28 +1,30 @@ -package com.ibrasoft.lensbridge.service.board.transformer; +package com.ibrasoft.lensbridge.service.board.producer; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.model.board.frames.PosterFrameConfig; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith(MockitoExtension.class) -class PosterFrameTransformerTest { +class PosterFrameProducerTest { @InjectMocks - private PosterFrameTransformer transformer; + private PosterFrameProducer transformer; private Poster poster(String title, String image, int duration) { return Poster.builder() + .id(UUID.randomUUID()) .title(title) .image(image) .duration(duration) @@ -32,18 +34,13 @@ private Poster poster(String title, String image, int duration) { .build(); } - @Test - void supportsReturnsPoster() { - assertThat(transformer.supports()).isEqualTo(FrameType.POSTER); - } - @Test void transformMapsPosterFieldsAndDuration() { - FrameDefinition def = transformer.transform(poster("Ramadan", "https://img/r.png", 15), null); + Poster p = poster("Ramadan", "https://img/r.png", 15); + FrameDefinition def = transformer.transform(p, null); + assertThat(def.getFrameId()).isEqualTo("poster:" + p.getId()); assertThat(def.getFrameType()).isEqualTo(FrameType.POSTER); - assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); - assertThat(def.getPriority()).isNull(); assertThat(def.getDurationInSeconds()).isEqualTo(15); assertThat(def.getFrameConfig()).isInstanceOf(PosterFrameConfig.class); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java similarity index 92% rename from src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java rename to src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java index bc07435..141b7be 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/WeeklyContentFrameTransformerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java @@ -1,13 +1,13 @@ -package com.ibrasoft.lensbridge.service.board.transformer; +package com.ibrasoft.lensbridge.service.board.producer; import com.ibrasoft.lensbridge.model.board.IslamicQuote; import com.ibrasoft.lensbridge.model.board.JummahPrayer; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; import com.ibrasoft.lensbridge.model.board.frames.FrameType; import com.ibrasoft.lensbridge.model.board.frames.IslamicQuoteFrameConfig; import com.ibrasoft.lensbridge.model.board.frames.JummahFrameConfig; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -16,17 +16,19 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith(MockitoExtension.class) -class WeeklyContentFrameTransformerTest { +class WeeklyContentFrameProducerTest { @InjectMocks - private WeeklyContentFrameTransformer transformer; + private WeeklyContentFrameProducer transformer; private IslamicQuote quote(IslamicQuote.Kind kind, String ref) { return IslamicQuote.builder() + .id(UUID.randomUUID()) .kind(kind) .arabic("arabic-" + ref) .transliteration("translit-" + ref) @@ -57,18 +59,18 @@ void emptyContentReturnsEmptyList() { @Test void verseQuoteMapsToVerseKindFrame() { + IslamicQuote q = quote(IslamicQuote.Kind.VERSE, "2:255"); WeeklyContent content = WeeklyContent.builder() - .quotes(List.of(quote(IslamicQuote.Kind.VERSE, "2:255"))) + .quotes(List.of(q)) .build(); List frames = transformer.transform(content, null); assertThat(frames).hasSize(1); FrameDefinition def = frames.get(0); + assertThat(def.getFrameId()).isEqualTo("quote:" + q.getId()); assertThat(def.getFrameType()).isEqualTo(FrameType.ISLAMIC_QUOTE); - assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); assertThat(def.getDurationInSeconds()).isNull(); - assertThat(def.getPriority()).isNull(); IslamicQuoteFrameConfig config = (IslamicQuoteFrameConfig) def.getFrameConfig(); assertThat(config.getKind()).isEqualTo(IslamicQuoteFrameConfig.Kind.VERSE); @@ -129,8 +131,9 @@ void jummahPrayersProduceSingleJummahFrameWithSlots() { assertThat(frames).hasSize(1); FrameDefinition def = frames.get(0); + // NB: the frameId transliterates as "jumuah" while the frame *type* is "jummah". + assertThat(def.getFrameId()).isEqualTo("jumuah"); assertThat(def.getFrameType()).isEqualTo(FrameType.JUMMAH); - assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); JummahFrameConfig config = (JummahFrameConfig) def.getFrameConfig(); assertThat(config.getPrayers()).hasSize(2); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java deleted file mode 100644 index 88b4d16..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/DailyScheduleFrameTransformerTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.BoardEvent; -import com.ibrasoft.lensbridge.model.board.frames.DailyScheduleFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Instant; -import java.util.Collections; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -class DailyScheduleFrameTransformerTest { - - @InjectMocks - private DailyScheduleFrameTransformer transformer; - - private BoardEvent event(String name) { - return BoardEvent.builder() - .name(name) - .description("desc-" + name) - .location("loc-" + name) - .startTime(Instant.parse("2026-05-15T09:00:00Z")) - .endTime(Instant.parse("2026-05-15T10:00:00Z")) - .allDay(false) - .audience(Audience.BOTH) - .build(); - } - - @Test - void supportsReturnsDailySchedule() { - assertThat(transformer.supports()).isEqualTo(FrameType.DAILY_SCHEDULE); - } - - @Test - void transformProducesDailyScheduleFrameWithExpectedShell() { - FrameDefinition def = transformer.transform(List.of(event("A")), null); - - assertThat(def.getFrameType()).isEqualTo(FrameType.DAILY_SCHEDULE); - assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); - assertThat(def.getDurationInSeconds()).isNull(); - assertThat(def.getPriority()).isNull(); - assertThat(def.getFrameConfig()).isInstanceOf(DailyScheduleFrameConfig.class); - } - - @Test - void transformMapsEventFieldsIntoEventViews() { - BoardEvent e = event("Lecture"); - - FrameDefinition def = transformer.transform(List.of(e), null); - - DailyScheduleFrameConfig config = (DailyScheduleFrameConfig) def.getFrameConfig(); - assertThat(config.getHeading()).isEqualTo("Today"); - assertThat(config.getEvents()).hasSize(1); - EventView view = config.getEvents().get(0); - assertThat(view.getName()).isEqualTo("Lecture"); - assertThat(view.getDescription()).isEqualTo("desc-Lecture"); - assertThat(view.getLocation()).isEqualTo("loc-Lecture"); - assertThat(view.getStartTime()).isEqualTo(Instant.parse("2026-05-15T09:00:00Z")); - assertThat(view.getEndTime()).isEqualTo(Instant.parse("2026-05-15T10:00:00Z")); - assertThat(view.getAllDay()).isFalse(); - } - - @Test - void transformPreservesEventOrderAndCount() { - FrameDefinition def = transformer.transform(List.of(event("A"), event("B"), event("C")), null); - - DailyScheduleFrameConfig config = (DailyScheduleFrameConfig) def.getFrameConfig(); - assertThat(config.getEvents()) - .extracting(EventView::getName) - .containsExactly("A", "B", "C"); - } - - @Test - void transformWithEmptyListProducesEmptyEventsButValidFrame() { - FrameDefinition def = transformer.transform(Collections.emptyList(), null); - - DailyScheduleFrameConfig config = (DailyScheduleFrameConfig) def.getFrameConfig(); - assertThat(config.getHeading()).isEqualTo("Today"); - assertThat(config.getEvents()).isEmpty(); - assertThat(def.getFrameType()).isEqualTo(FrameType.DAILY_SCHEDULE); - } -} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java deleted file mode 100644 index 09d4962..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/transformer/EventListFrameTransformerTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.transformer; - -import com.ibrasoft.lensbridge.dto.board.response.frames.EventView; -import com.ibrasoft.lensbridge.model.board.Audience; -import com.ibrasoft.lensbridge.model.board.BoardEvent; -import com.ibrasoft.lensbridge.model.board.frames.EventListFrameConfig; -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameSlot; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Instant; -import java.util.Collections; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -class EventListFrameTransformerTest { - - @InjectMocks - private EventListFrameTransformer transformer; - - private BoardEvent event(String name, boolean allDay) { - return BoardEvent.builder() - .name(name) - .description("desc-" + name) - .location("loc-" + name) - .startTime(Instant.parse("2026-05-12T00:00:00Z")) - .endTime(Instant.parse("2026-05-12T23:59:59Z")) - .allDay(allDay) - .audience(Audience.BROTHERS) - .build(); - } - - @Test - void supportsReturnsEventList() { - assertThat(transformer.supports()).isEqualTo(FrameType.EVENT_LIST); - } - - @Test - void transformProducesEventListFrameWithExpectedShell() { - FrameDefinition def = transformer.transform(List.of(event("A", false)), null); - - assertThat(def.getFrameType()).isEqualTo(FrameType.EVENT_LIST); - assertThat(def.getSlot()).isEqualTo(FrameSlot.PRIMARY); - assertThat(def.getDurationInSeconds()).isNull(); - assertThat(def.getPriority()).isNull(); - assertThat(def.getFrameConfig()).isInstanceOf(EventListFrameConfig.class); - } - - @Test - void transformMapsEventFieldsIncludingAllDay() { - FrameDefinition def = transformer.transform(List.of(event("Conference", true)), null); - - EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); - assertThat(config.getHeading()).isEqualTo("Next 7 Days"); - assertThat(config.getEvents()).hasSize(1); - EventView view = config.getEvents().get(0); - assertThat(view.getName()).isEqualTo("Conference"); - assertThat(view.getDescription()).isEqualTo("desc-Conference"); - assertThat(view.getLocation()).isEqualTo("loc-Conference"); - assertThat(view.getStartTime()).isEqualTo(Instant.parse("2026-05-12T00:00:00Z")); - assertThat(view.getEndTime()).isEqualTo(Instant.parse("2026-05-12T23:59:59Z")); - assertThat(view.getAllDay()).isTrue(); - } - - @Test - void transformPreservesEventOrderAndCount() { - FrameDefinition def = transformer.transform( - List.of(event("A", false), event("B", false)), null); - - EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); - assertThat(config.getEvents()) - .extracting(EventView::getName) - .containsExactly("A", "B"); - } - - @Test - void transformWithEmptyListProducesEmptyEventsButValidFrame() { - FrameDefinition def = transformer.transform(Collections.emptyList(), null); - - EventListFrameConfig config = (EventListFrameConfig) def.getFrameConfig(); - assertThat(config.getHeading()).isEqualTo("Next 7 Days"); - assertThat(config.getEvents()).isEmpty(); - assertThat(def.getFrameType()).isEqualTo(FrameType.EVENT_LIST); - } -} From e07707b8d9ed556fab15c240b357e10d0a82354f Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 16:58:23 -0400 Subject: [PATCH 41/52] Scaffolding around arbitrary social media frames --- .../lensbridge/model/board/Audience.java | 5 ++ .../lensbridge/model/board/Poster.java | 2 +- .../model/board/PromotableSocialMedia.java | 76 +++++++++++++++++++ .../lensbridge/model/board/SocialType.java | 18 +++++ .../model/board/frames/FrameConfig.java | 14 ++-- .../model/board/frames/FrameType.java | 3 +- .../PromotableSocialMediaFrameConfig.java | 38 ++++++++++ 7 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java index ca47bab..f22fb5a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Audience.java @@ -6,6 +6,11 @@ import java.util.Arrays; import java.util.Optional; +// TECH DEBT: +// Eventually I would like to have tenancy-defined audiences and allow audience meshing rather than a binary +// brothers/sisters/everyone (i.e: more than 3 audiences) +// This is sufficient for a version 1 prototype, though + public enum Audience { BROTHERS("brothers"), SISTERS("sisters"), diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java index 1237925..9c2cc3d 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Poster.java @@ -32,9 +32,9 @@ public class Poster { @Column(nullable = false) private Audience audience; - @Column(nullable = true) /** * Optional URL for the associated signup page. If provided, the frontend will show a QR Code linking to this URL. */ + @Column(nullable = true) private String signupUrl; } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java b/src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java new file mode 100644 index 0000000..8b65fa3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/PromotableSocialMedia.java @@ -0,0 +1,76 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * Represents a social media platform that can be promoted to a board + * Includes platforms like Instagram pages, TikTok accounts, YouTube channels, and + * WhatsApp chats + */ +@Entity +@Table(name = "promotable_social_media") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotableSocialMedia { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String name; + + /** + * Duration of frame, in seconds + */ + @Column(nullable = false) + private int duration; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Audience audience; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private SocialType type; + + @Column(nullable = false) + private String url; + + /* All fields below will be stored as markdown in the database and serialized to HTML in the frontend + That way, users can add italics for things like "your home on campus" + */ + + /** + * Small 2-3 word tagline (i.e: follow along) + */ + @Column(nullable = false) + private String headerText; + + /** + * Main text shown to the user + */ + @Column(nullable = false) + private String heroText; + + /** + * Social media handle, if applicable (i.e: WhatsApp doesn't have a handle) + */ + @Column(nullable = true) + private String handle; + + /** + * Descriptive block of text + * i.e: Follow your home on campus for event recaps, announcements, and more! + */ + @Column(nullable = false) + private String footerText; + +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java new file mode 100644 index 0000000..e6194d3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java @@ -0,0 +1,18 @@ +package com.ibrasoft.lensbridge.model.board; + +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +public enum SocialType { + INSTAGRAM("instagram"), + YOUTUBE("youtube"), + TIKTOK("tiktok"), + WHATSAPP("whatsapp"),; + + private final String label; + + SocialType(String label) { + this.label = label; + } +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java index 1c01542..a800d97 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java @@ -7,7 +7,7 @@ * Base class for frame-specific configuration. * Uses Jackson annotations for polymorphic serialization. *

- * {@link FrameType#NEXT_PRAYER} and {@link FrameType#INSTAGRAM} use empty marker configs + * {@link FrameType#NEXT_PRAYER} uses empty marker configs * ({@link NextPrayerFrameConfig}, {@link InstagramFrameConfig}) — the backend has no data to * contribute to either (prayer times and the QR destination are both resolved client-side), but * the frame still needs a real position and {@code durationInSeconds} in the sequence rather @@ -19,12 +19,12 @@ property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = PosterFrameConfig.class, name = "poster"), - @JsonSubTypes.Type(value = JummahFrameConfig.class, name = "jummah"), - @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote"), - @JsonSubTypes.Type(value = NextPrayerFrameConfig.class, name = "next_prayer"), - @JsonSubTypes.Type(value = AgendaFrameConfig.class, name = "agenda"), - @JsonSubTypes.Type(value = InstagramFrameConfig.class, name = "instagram") + @JsonSubTypes.Type(value = PosterFrameConfig.class, name = "poster"), + @JsonSubTypes.Type(value = JummahFrameConfig.class, name = "jummah"), + @JsonSubTypes.Type(value = IslamicQuoteFrameConfig.class, name = "islamic_quote"), + @JsonSubTypes.Type(value = NextPrayerFrameConfig.class, name = "next_prayer"), + @JsonSubTypes.Type(value = AgendaFrameConfig.class, name = "agenda"), + @JsonSubTypes.Type(value = PromotableSocialMediaFrameConfig.class, name = "socials") }) public abstract class FrameConfig { diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java index 538ee54..671ff89 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java @@ -23,7 +23,8 @@ public enum FrameType { AGENDA("agenda"), INSTAGRAM("instagram"), JUMMAH("jummah"), - ISLAMIC_QUOTE("islamic_quote"); + ISLAMIC_QUOTE("islamic_quote"), + SOCIALS("socials"); private final String typeName; FrameType(String typeName) { diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java new file mode 100644 index 0000000..db2325f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java @@ -0,0 +1,38 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import com.ibrasoft.lensbridge.model.board.SocialType; +import jakarta.persistence.Column; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@AllArgsConstructor +@Builder +@Data +public class PromotableSocialMediaFrameConfig extends FrameConfig { + private SocialType socialType; + + private String url; + + /** + * Small 2-3 word tagline (i.e: follow along) + */ + private String headerText; + + /** + * Main text shown to the user + */ + private String heroText; + + /** + * Social media handle, if applicable (i.e: WhatsApp doesn't have a handle) + */ + private String handle; + + /** + * Descriptive block of text + * i.e: Follow your home on campus for event recaps, announcements, and more! + */ + private String footerText; +} From fdbc382573799a4aaf0c56c2f9af0ed03d3ac6d6 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 21:16:51 -0400 Subject: [PATCH 42/52] Finalized Social Media Frames + flyaway db migrations --- openapi.yaml | 316 +++++++++++++++++- pom.xml | 21 ++ .../StringToSocialTypeConverter.java | 20 ++ .../controller/BoardAdminController.java | 73 ++++ .../CreatePromotableSocialMediaRequest.java | 45 +++ .../request/UpdateBoardConfigRequest.java | 9 +- .../UpdatePromotableSocialMediaRequest.java | 37 ++ .../exception/GlobalExceptionHandler.java | 7 +- .../lensbridge/model/audit/AuditAction.java | 5 + .../lensbridge/model/auth/Permission.java | 3 + .../model/auth/RolePermissions.java | 1 + .../lensbridge/model/board/SocialType.java | 50 ++- .../model/board/embedded/DeviceConfig.java | 12 - .../model/board/frames/FrameConfig.java | 15 +- .../model/board/frames/FrameType.java | 1 - .../board/frames/InstagramFrameConfig.java | 15 - .../PromotableSocialMediaFrameConfig.java | 16 +- .../sql/PromotableSocialMediaRepository.java | 30 ++ .../lensbridge/service/BoardService.java | 5 - .../service/PromotableSocialMediaService.java | 138 ++++++++ .../producer/InstagramFrameProducer.java | 32 -- .../board/producer/SocialsFrameProducer.java | 58 ++++ .../resources/application.properties.example | 9 +- .../postgresql/V1__initial_schema.sql | 50 +++ .../migration/sqlite/V1__initial_schema.sql | 47 +++ .../StringToSocialTypeConverterTest.java | 42 +++ .../BoardAdminControllerPermissionTest.java | 7 + .../BoardAdminControllerUpdateDeviceTest.java | 3 + .../exception/GlobalExceptionHandlerTest.java | 47 +++ .../migration/MigrationParityTest.java | 84 +++++ .../model/board/SocialTypeJsonTest.java | 58 ++++ .../model/board/SocialTypeTest.java | 45 +++ .../frames/FrameConfigDiscriminatorTest.java | 70 ++++ .../PromotableSocialMediaServiceTest.java | 246 ++++++++++++++ .../producer/InstagramFrameProducerTest.java | 28 -- .../producer/SocialsFrameProducerTest.java | 115 +++++++ 36 files changed, 1637 insertions(+), 123 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java create mode 100644 src/main/resources/db/migration/postgresql/V1__initial_schema.sql create mode 100644 src/main/resources/db/migration/sqlite/V1__initial_schema.sql create mode 100644 src/test/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverterTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/migration/MigrationParityTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeJsonTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfigDiscriminatorTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaServiceTest.java delete mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducerTest.java diff --git a/openapi.yaml b/openapi.yaml index 7f9ef09..79ccc6f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -76,6 +76,9 @@ paths: - CREATE_POSTER - UPDATE_POSTER - DELETE_POSTER + - CREATE_SOCIAL + - UPDATE_SOCIAL + - DELETE_SOCIAL - SAVE_WEEKLY_CONTENT - DELETE_WEEKLY_CONTENT - UPDATE_BOARD_CONFIG @@ -168,6 +171,9 @@ paths: - CREATE_POSTER - UPDATE_POSTER - DELETE_POSTER + - CREATE_SOCIAL + - UPDATE_SOCIAL + - DELETE_SOCIAL - SAVE_WEEKLY_CONTENT - DELETE_WEEKLY_CONTENT - UPDATE_BOARD_CONFIG @@ -1039,6 +1045,158 @@ paths: description: Request failed; body carries a human-readable message tags: - board-admin-controller + /api/admin/board/socials: + get: + operationId: getAllSocials + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + post: + operationId: createSocial + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePromotableSocialMediaRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/socials/by-audience: + get: + operationId: getSocialsForAudience + parameters: + - in: query + name: audience + required: true + schema: + type: string + enum: + - brothers + - sisters + - both + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + /api/admin/board/socials/{socialId}: + delete: + operationId: deleteSocial + parameters: + - in: path + name: socialId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + get: + operationId: getSocialById + parameters: + - in: path + name: socialId + required: true + schema: + type: string + format: uuid + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller + patch: + operationId: updateSocial + parameters: + - in: path + name: socialId + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePromotableSocialMediaRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PromotableSocialMedia" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + tags: + - board-admin-controller /api/admin/board/weekly-content: get: operationId: getAllWeeklyContent @@ -1703,6 +1861,7 @@ paths: - BOARD_CONTENT_READ - BOARD_POSTER_WRITE - BOARD_EVENT_WRITE + - BOARD_SOCIAL_WRITE - BOARD_WEEKLY_WRITE - BOARD_TICKER_WRITE - BOARD_CONFIG_READ @@ -1797,6 +1956,7 @@ paths: - BOARD_CONTENT_READ - BOARD_POSTER_WRITE - BOARD_EVENT_WRITE + - BOARD_SOCIAL_WRITE - BOARD_WEEKLY_WRITE - BOARD_TICKER_WRITE - BOARD_CONFIG_READ @@ -3024,6 +3184,9 @@ components: - CREATE_POSTER - UPDATE_POSTER - DELETE_POSTER + - CREATE_SOCIAL + - UPDATE_SOCIAL + - DELETE_SOCIAL - SAVE_WEEKLY_CONTENT - DELETE_WEEKLY_CONTENT - UPDATE_BOARD_CONFIG @@ -3267,6 +3430,51 @@ components: - imageFile - startTime - title + CreatePromotableSocialMediaRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + footerText: + type: string + minLength: 1 + handle: + type: string + headerText: + type: string + minLength: 1 + heroText: + type: string + minLength: 1 + name: + type: string + minLength: 1 + type: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string + minLength: 1 + required: + - audience + - footerText + - headerText + - heroText + - name + - type + - url CreateUserRequest: type: object properties: @@ -3332,8 +3540,6 @@ components: type: array items: type: string - socialUrl: - type: string DeviceSummary: type: object properties: @@ -3382,6 +3588,13 @@ components: format: date-time FrameConfig: discriminator: + mapping: + agenda: "#/components/schemas/AgendaFrameConfig" + islamic_quote: "#/components/schemas/IslamicQuoteFrameConfig" + jummah: "#/components/schemas/JummahFrameConfig" + next_prayer: "#/components/schemas/NextPrayerFrameConfig" + poster: "#/components/schemas/PosterFrameConfig" + socials: "#/components/schemas/PromotableSocialMediaFrameConfig" propertyName: type properties: type: @@ -3397,11 +3610,11 @@ components: frameConfig: oneOf: - $ref: "#/components/schemas/AgendaFrameConfig" - - $ref: "#/components/schemas/InstagramFrameConfig" - $ref: "#/components/schemas/IslamicQuoteFrameConfig" - $ref: "#/components/schemas/JummahFrameConfig" - $ref: "#/components/schemas/NextPrayerFrameConfig" - $ref: "#/components/schemas/PosterFrameConfig" + - $ref: "#/components/schemas/PromotableSocialMediaFrameConfig" frameId: type: string frameType: @@ -3410,9 +3623,9 @@ components: - poster - next_prayer - agenda - - instagram - jummah - islamic_quote + - socials GalleryItemDto: type: object properties: @@ -3434,9 +3647,6 @@ components: type: string type: type: string - InstagramFrameConfig: - allOf: - - $ref: "#/components/schemas/FrameConfig" IslamicQuote: type: object properties: @@ -3898,6 +4108,64 @@ components: type: string uploadUrl: type: string + PromotableSocialMedia: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + footerText: + type: string + handle: + type: string + headerText: + type: string + heroText: + type: string + id: + type: string + format: uuid + name: + type: string + type: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string + PromotableSocialMediaFrameConfig: + allOf: + - $ref: "#/components/schemas/FrameConfig" + - type: object + properties: + footerText: + type: string + handle: + type: string + headerText: + type: string + heroText: + type: string + socialType: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string QuoteEntry: type: object properties: @@ -4043,8 +4311,6 @@ components: type: array items: type: string - socialUrl: - type: string UpdateCalendarEventRequest: type: object properties: @@ -4121,6 +4387,38 @@ components: type: string maxLength: 10 minLength: 0 + UpdatePromotableSocialMediaRequest: + type: object + properties: + audience: + type: string + enum: + - brothers + - sisters + - both + duration: + type: integer + format: int32 + footerText: + type: string + handle: + type: string + headerText: + type: string + heroText: + type: string + name: + type: string + type: + type: string + enum: + - instagram + - youtube + - tiktok + - whatsapp + - other + url: + type: string UpdateTickerRequest: type: object properties: diff --git a/pom.xml b/pom.xml index d15005a..01426ac 100644 --- a/pom.xml +++ b/pom.xml @@ -174,6 +174,27 @@ 3.47.1.0 + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + org.hibernate.orm hibernate-community-dialects diff --git a/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java b/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java new file mode 100644 index 0000000..6bb8010 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverter.java @@ -0,0 +1,20 @@ +package com.ibrasoft.lensbridge.config.converter; + +import com.ibrasoft.lensbridge.model.board.SocialType; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +/** + * Binds {@code SocialType} path and query params. Spring's default enum binder matches on + * {@code name()} only, so without this a request carrying the lowercase form the API itself + * emits — {@code ?type=instagram} — would 400. + */ +@Component +public class StringToSocialTypeConverter implements Converter { + @Override + public SocialType convert(@NonNull String source) { + return SocialType.from(source) + .orElseThrow(() -> new IllegalArgumentException("Unknown social type: " + source)); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java index 4330099..2f8ae52 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/BoardAdminController.java @@ -2,6 +2,8 @@ import com.ibrasoft.lensbridge.dto.board.request.CreateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.CreatePosterRequest; +import com.ibrasoft.lensbridge.dto.board.request.CreatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePromotableSocialMediaRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateBoardConfigRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateCalendarEventRequest; import com.ibrasoft.lensbridge.dto.board.request.UpdateDeviceRequest; @@ -17,11 +19,13 @@ import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.Poster; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -56,6 +60,7 @@ public class BoardAdminController { private final PosterService posterService; + private final PromotableSocialMediaService socialMediaService; private final BoardService boardService; private final AdminAuditService auditService; private final BoardStreamHandler boardStream; @@ -283,6 +288,74 @@ public ResponseEntity deletePoster( return ResponseEntity.ok(new MessageResponse("Poster deleted successfully")); } + // ==================== Promotable Social Media Endpoints ==================== + + @GetMapping("/socials") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity> getAllSocials() { + log.debug("Admin fetching all promotable social media"); + return ResponseEntity.ok(socialMediaService.getAll()); + } + + @GetMapping("/socials/by-audience") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity> getSocialsForAudience(@RequestParam Audience audience) { + log.debug("Admin fetching promotable social media for audience: {}", audience); + return ResponseEntity.ok(socialMediaService.getForAudience(audience)); + } + + @GetMapping("/socials/{socialId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')") + public ResponseEntity getSocialById(@PathVariable UUID socialId) { + log.debug("Admin fetching promotable social media: {}", socialId); + return ResponseEntity.ok(socialMediaService.getById(socialId)); + } + + @PostMapping("/socials") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_SOCIAL_WRITE + "')") + public ResponseEntity createSocial( + @Valid @RequestBody CreatePromotableSocialMediaRequest createRequest, + HttpServletRequest request) { + + log.info("Admin creating promotable social media: type={}, name={}", + createRequest.getType(), createRequest.getName()); + + PromotableSocialMedia response = socialMediaService.create(createRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.CREATE_SOCIAL, "PromotableSocialMedia", response.getId(), request.getRemoteAddr()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PatchMapping("/socials/{socialId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_SOCIAL_WRITE + "')") + public ResponseEntity updateSocial( + @PathVariable UUID socialId, + @Valid @RequestBody UpdatePromotableSocialMediaRequest updateRequest, + HttpServletRequest request) { + + log.info("Admin updating promotable social media: {}", socialId); + PromotableSocialMedia response = socialMediaService.update(socialId, updateRequest); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.UPDATE_SOCIAL, "PromotableSocialMedia", socialId, request.getRemoteAddr()); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/socials/{socialId}") + @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_SOCIAL_WRITE + "')") + public ResponseEntity deleteSocial( + @PathVariable UUID socialId, + HttpServletRequest request) { + + log.info("Admin deleting promotable social media: {}", socialId); + socialMediaService.delete(socialId); + + auditService.logAuditEvent(getCurrentUserEmail(), AuditAction.DELETE_SOCIAL, "PromotableSocialMedia", socialId, request.getRemoteAddr()); + + return ResponseEntity.ok(new MessageResponse("Promotable social media deleted successfully")); + } + // ==================== Calendar Event Endpoints ==================== @GetMapping("/events") diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java new file mode 100644 index 0000000..e71c5d2 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/CreatePromotableSocialMediaRequest.java @@ -0,0 +1,45 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.SocialType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.*; +import org.hibernate.validator.constraints.URL; + +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class CreatePromotableSocialMediaRequest { + + /** Admin-facing label, not shown on the board. */ + @NotBlank(message = "Name is required") + private String name; + + @Positive(message = "Duration must be positive") + private int duration; + + @NotNull(message = "Audience is required") + private Audience audience; + + @NotNull(message = "Social type is required") + private SocialType type; + + /** QR destination. */ + @NotBlank(message = "URL is required") + @URL(message = "URL must be a valid URL") + private String url; + + // The text fields below are stored as markdown and rendered to HTML by the frontend. + + @NotBlank(message = "Header text is required") + private String headerText; + + @NotBlank(message = "Hero text is required") + private String heroText; + + /** Optional — platforms like WhatsApp have no handle. */ + private String handle; + + @NotBlank(message = "Footer text is required") + private String footerText; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java index 2f2ccad..e1976d1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java @@ -15,19 +15,12 @@ public class UpdateBoardConfigRequest { private Boolean enableScrollingMessage; private List scrollingMessages; - /** - * Destination for the "Stay Connected" QR code on the board's closing slide - * Typically a social media page, but could be any URL - */ - private String socialUrl; - /** * Seconds to pin the agenda slide for, or {@code 0} to hand the decision back to the board * ("auto"). * * Zero rather than null carries that meaning because this endpoint patches by skipping - * nulls — null already means "leave it alone", so it cannot also mean "clear it". Same - * reason {@link #socialUrl} treats the empty string as a clear. + * nulls — null already means "leave it alone", so it cannot also mean "clear it". * * The 1–4 gap is rejected in {@code BoardService}: bean validation cannot express * "zero or at least five" without a custom constraint that would leak a bogus property diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java new file mode 100644 index 0000000..684baf3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdatePromotableSocialMediaRequest.java @@ -0,0 +1,37 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.SocialType; +import jakarta.validation.constraints.Positive; +import lombok.*; +import org.hibernate.validator.constraints.URL; + +/** + * Patch request: null leaves a field alone. The one exception is {@link #handle}, where an + * empty string clears it — a page that drops its handle would otherwise be unfixable without + * deleting and recreating the entry. + */ +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class UpdatePromotableSocialMediaRequest { + + private String name; + + @Positive(message = "Duration must be positive") + private Integer duration; + + private Audience audience; + + private SocialType type; + + @URL(message = "URL must be a valid URL") + private String url; + + private String headerText; + + private String heroText; + + /** Send an empty string to clear it. */ + private String handle; + + private String footerText; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java index 1ab05ba..b2aba38 100644 --- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java @@ -151,10 +151,13 @@ protected ResponseEntity handleMaxUploadSizeExceededException(MaxUploadS body.put("status", HttpStatus.PAYLOAD_TOO_LARGE.value()); return new ResponseEntity<>(body, headers, HttpStatus.PAYLOAD_TOO_LARGE); } - + @ExceptionHandler(ApiResponseException.class) public ResponseEntity handleApiResponseException(ApiResponseException ex) { - return ResponseEntity.status(ex.getStatus()).body(ex.getBody()); + Object body = ex.getBody() instanceof ErrorResponse error + ? new MessageResponse(error.getError()) + : ex.getBody(); + return ResponseEntity.status(ex.getStatus()).body(body); } @ExceptionHandler(RefreshTokenException.class) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java index a4ada93..6df17b8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/audit/AuditAction.java @@ -26,6 +26,11 @@ public enum AuditAction { UPDATE_POSTER("Update Poster"), DELETE_POSTER("Delete Poster"), + // Promotable Social Media Actions + CREATE_SOCIAL("Create Promotable Social Media"), + UPDATE_SOCIAL("Update Promotable Social Media"), + DELETE_SOCIAL("Delete Promotable Social Media"), + // Board Content Actions (Musallah Board) // SAVE_WEEKLY_CONTENT is the only record of who last changed a jummah time, since // quotes and prayer times share one permission and one endpoint. diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java index 7d48f4d..7bb326a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/Permission.java @@ -31,6 +31,8 @@ public enum Permission implements GrantedAuthority { BOARD_CONTENT_READ(Authority.BOARD_CONTENT_READ), BOARD_POSTER_WRITE(Authority.BOARD_POSTER_WRITE), BOARD_EVENT_WRITE(Authority.BOARD_EVENT_WRITE), + /** The social accounts promoted to a board. Carries a QR destination, so it publishes a link. */ + BOARD_SOCIAL_WRITE(Authority.BOARD_SOCIAL_WRITE), /** Quotes and jummah times together — they share one endpoint. See docs/PERMISSIONS.md §3. */ BOARD_WEEKLY_WRITE(Authority.BOARD_WEEKLY_WRITE), /** Ticker copy only. Deliberately not BOARD_CONFIG_WRITE, which can move the board's coordinates. */ @@ -92,6 +94,7 @@ public interface Authority { String BOARD_CONTENT_READ = "board:content:read"; String BOARD_POSTER_WRITE = "board:poster:write"; String BOARD_EVENT_WRITE = "board:event:write"; + String BOARD_SOCIAL_WRITE = "board:social:write"; String BOARD_WEEKLY_WRITE = "board:weekly:write"; String BOARD_TICKER_WRITE = "board:ticker:write"; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java b/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java index da203a9..65d68c1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/auth/RolePermissions.java @@ -49,6 +49,7 @@ final class RolePermissions { Set boardEditor = union(boardViewer, EnumSet.of( Permission.BOARD_POSTER_WRITE, Permission.BOARD_EVENT_WRITE, + Permission.BOARD_SOCIAL_WRITE, Permission.BOARD_WEEKLY_WRITE, Permission.BOARD_TICKER_WRITE, Permission.BOARD_REFRESH)); diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java index e6194d3..05654e0 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/SocialType.java @@ -1,18 +1,58 @@ package com.ibrasoft.lensbridge.model.board; -import jakarta.persistence.Entity; -import lombok.Getter; -import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; +import java.util.Optional; + +/** + * The platform a {@link PromotableSocialMedia} entry points at, as it appears in + * {@code PromotableSocialMediaFrameConfig.socialType}. + *

+ * {@code @JsonValue} is crucial, else Jackson serializes the enum name and the wire carries + * {@code INSTAGRAM} where the frontend — which keys its platform icons and brand colours off + * the lowercase label — expects {@code instagram}. + *

+ * Mirrors {@link Audience} and {@link com.ibrasoft.lensbridge.model.board.frames.FrameType}, + * which had the same shape and got this right. + *

+ * This affects the wire only. The column is {@code @Enumerated(EnumType.STRING)}, so the + * database still stores {@code name()}. + */ public enum SocialType { INSTAGRAM("instagram"), YOUTUBE("youtube"), TIKTOK("tiktok"), - WHATSAPP("whatsapp"),; + WHATSAPP("whatsapp"), + OTHER("other"); private final String label; SocialType(String label) { this.label = label; } -} \ No newline at end of file + + public static Optional from(String s) { + if (s == null) return Optional.empty(); + return Arrays.stream(values()) + .filter(t -> t.name().equalsIgnoreCase(s) || t.label.equalsIgnoreCase(s)) + .findFirst(); + } + + @JsonValue + public String toJson() { + return label; + } + + /** Accepts either spelling, so anything persisted or sent as {@code INSTAGRAM} still binds. */ + @JsonCreator + public static SocialType fromJson(String s) { + return from(s).orElseThrow(() -> new IllegalArgumentException("Unknown social type: " + s)); + } + + @Override + public String toString() { + return label; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java index c3ecd73..bc4395f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/embedded/DeviceConfig.java @@ -33,18 +33,6 @@ public class DeviceConfig { @Column(name = "message") private List scrollingMessages; - /** - * Destination for the "Stay Connected" QR code on the closing slide. Optional: - * a board with no socialUrl renders that slide without a QR. - * - * Deliberately nullable. NOT NULL could not be satisfied here -- devices are - * enrolled with a default config that does not set this - * (DeviceEnrollmentService), and rows created before the column exists have - * no value to backfill from. - */ - @Column(name = "social_url") - private String socialUrl; - /** Shortest and longest a slide may be pinned for. A 2s slide cannot be read; a 5m one is a hang. */ public static final int MIN_SLIDE_SECONDS = 5; public static final int MAX_SLIDE_SECONDS = 120; diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java index a800d97..62ddafc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfig.java @@ -2,13 +2,15 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; /** * Base class for frame-specific configuration. * Uses Jackson annotations for polymorphic serialization. *

* {@link FrameType#NEXT_PRAYER} uses empty marker configs - * ({@link NextPrayerFrameConfig}, {@link InstagramFrameConfig}) — the backend has no data to + * ({@link NextPrayerFrameConfig}) — the backend has no data to * contribute to either (prayer times and the QR destination are both resolved client-side), but * the frame still needs a real position and {@code durationInSeconds} in the sequence rather * than being synthesized outside the API contract. @@ -26,6 +28,17 @@ @JsonSubTypes.Type(value = AgendaFrameConfig.class, name = "agenda"), @JsonSubTypes.Type(value = PromotableSocialMediaFrameConfig.class, name = "socials") }) +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "poster", schema = PosterFrameConfig.class), + @DiscriminatorMapping(value = "jummah", schema = JummahFrameConfig.class), + @DiscriminatorMapping(value = "islamic_quote", schema = IslamicQuoteFrameConfig.class), + @DiscriminatorMapping(value = "next_prayer", schema = NextPrayerFrameConfig.class), + @DiscriminatorMapping(value = "agenda", schema = AgendaFrameConfig.class), + @DiscriminatorMapping(value = "socials", schema = PromotableSocialMediaFrameConfig.class) + } +) public abstract class FrameConfig { } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java index 671ff89..867cecc 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/FrameType.java @@ -21,7 +21,6 @@ public enum FrameType { POSTER("poster"), NEXT_PRAYER("next_prayer"), AGENDA("agenda"), - INSTAGRAM("instagram"), JUMMAH("jummah"), ISLAMIC_QUOTE("islamic_quote"), SOCIALS("socials"); diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java deleted file mode 100644 index c03aa70..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/InstagramFrameConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ibrasoft.lensbridge.model.board.frames; - -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * Marker config for {@link FrameType#INSTAGRAM}. Carries no fields: the QR destination is - * read client-side from {@code deviceConfig.socialUrl}, with its own app-level fallback when - * that is unset. This class exists only so the frame has a real position and - * {@code durationInSeconds} in the sequence, instead of being synthesized client-side. - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class InstagramFrameConfig extends FrameConfig { -} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java index db2325f..820bde8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/frames/PromotableSocialMediaFrameConfig.java @@ -1,16 +1,24 @@ package com.ibrasoft.lensbridge.model.board.frames; import com.ibrasoft.lensbridge.model.board.SocialType; -import jakarta.persistence.Column; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; -@AllArgsConstructor -@Builder +/** + * One promoted social account, as the board renders it: platform, QR destination, and the copy + * around them. All of it comes from a {@link com.ibrasoft.lensbridge.model.board.PromotableSocialMedia} + * row, so an operator can add or reword an account without a frontend deploy. + */ @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) public class PromotableSocialMediaFrameConfig extends FrameConfig { + private SocialType socialType; private String url; diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java new file mode 100644 index 0000000..e1dd3c5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PromotableSocialMediaRepository.java @@ -0,0 +1,30 @@ +package com.ibrasoft.lensbridge.repository.sql; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.SocialType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface PromotableSocialMediaRepository extends JpaRepository { + + List findAllByOrderByNameAsc(); + + /** + * Socials a board with this audience should show. Ordered by name so the frame sequence + * is stable between assemblies — there is no explicit ordering column, and an unordered + * result would let the same board reshuffle its social frames on every refetch. + */ + @Query("SELECT s FROM PromotableSocialMedia s WHERE (s.audience = :aud OR s.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY s.name ASC") + List findByAudienceOrBoth(@Param("aud") Audience audience); + + List findByTypeOrderByNameAsc(SocialType type); + + boolean existsByTypeAndUrl(SocialType type, String url); +} \ No newline at end of file diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 7b26362..1413c5f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -71,11 +71,6 @@ public DeviceConfig updateBoardConfig(UUID deviceId, UpdateBoardConfigRequest re Patch.apply(request.getDarkModeAfterIsha(), existing::setDarkModeAfterIsha); Patch.apply(request.getEnableScrollingMessage(), existing::setEnableScrollingMessage); Patch.apply(request.getScrollingMessages(), existing::setScrollingMessages); - // Empty string clears the link, which drops the QR from the closing slide. - if (request.getSocialUrl() != null) { - String socialUrl = request.getSocialUrl().trim(); - existing.setSocialUrl(socialUrl.isEmpty() ? null : socialUrl); - } applyAgendaDuration(existing, request.getAgendaDurationSeconds()); Patch.apply(request.getNextPrayerDurationSeconds(), existing::setNextPrayerDurationSeconds); DeviceConfig saved = boardConfigRepository.save(existing); diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java b/src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java new file mode 100644 index 0000000..6c3c142 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaService.java @@ -0,0 +1,138 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.board.request.CreatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.SocialType; +import com.ibrasoft.lensbridge.repository.sql.PromotableSocialMediaRepository; +import com.ibrasoft.lensbridge.util.Patch; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +/** + * CRUD for the social accounts a board can promote. Unlike posters these have no viewing + * window — a social page is either promoted or it isn't — so the only filter that matters + * for assembly is audience. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PromotableSocialMediaService { + + private final PromotableSocialMediaRepository repository; + private final BoardStreamHandler boardStream; + + /** Every promotable social, ordered by name. */ + public List getAll() { + return repository.findAllByOrderByNameAsc(); + } + + public PromotableSocialMedia getById(UUID id) { + return repository.findById(id) + .orElseThrow(() -> new ApiResponseException( + HttpStatus.NOT_FOUND, + ErrorResponse.of("Promotable social media not found with id: " + id))); + } + + /** + * Socials a board should show: those targeting this audience plus those targeting BOTH. + * This is what the socials frame producer consumes. + */ + public List getForAudience(Audience audience) { + return repository.findByAudienceOrBoth(audience); + } + + public List getByType(SocialType type) { + return repository.findByTypeOrderByNameAsc(type); + } + + public PromotableSocialMedia create(CreatePromotableSocialMediaRequest request) { + String url = normalize(request.getUrl()); + if (repository.existsByTypeAndUrl(request.getType(), url)) { + throw new ApiResponseException( + HttpStatus.CONFLICT, + ErrorResponse.of("That " + request.getType() + " URL is already promoted")); + } + + PromotableSocialMedia social = PromotableSocialMedia.builder() + .name(request.getName()) + .duration(request.getDuration()) + .audience(request.getAudience()) + .type(request.getType()) + .url(url) + .headerText(request.getHeaderText()) + .heroText(request.getHeroText()) + .handle(blankToNull(request.getHandle())) + .footerText(request.getFooterText()) + .build(); + + social = repository.save(social); + log.info("Created promotable social media: id={}, type={}, name={}", + social.getId(), social.getType(), social.getName()); + boardStream.contentChanged("socials"); + + return social; + } + + public PromotableSocialMedia update(UUID id, UpdatePromotableSocialMediaRequest request) { + PromotableSocialMedia social = getById(id); + + Patch.apply(request.getName(), social::setName); + Patch.apply(request.getDuration(), social::setDuration); + Patch.apply(request.getAudience(), social::setAudience); + Patch.apply(request.getType(), social::setType); + Patch.apply(request.getHeaderText(), social::setHeaderText); + Patch.apply(request.getHeroText(), social::setHeroText); + Patch.apply(request.getFooterText(), social::setFooterText); + + if (request.getUrl() != null) { + social.setUrl(normalize(request.getUrl())); + } + // Not Patch.apply: an empty string means "clear the handle", which the null-guard + // there would let through unchanged. + if (request.getHandle() != null) { + social.setHandle(blankToNull(request.getHandle())); + } + + social = repository.save(social); + log.info("Updated promotable social media: id={}", id); + boardStream.contentChanged("socials"); + + return social; + } + + public void delete(UUID id) { + PromotableSocialMedia social = getById(id); + + repository.delete(social); + log.info("Deleted promotable social media: id={}", id); + boardStream.contentChanged("socials"); + } + + // ==================== Helper Methods ==================== + + private static String normalize(String url) { + String trimmed = url == null ? "" : url.trim(); + if (trimmed.isEmpty()) { + throw new ApiResponseException( + HttpStatus.BAD_REQUEST, + ErrorResponse.of("URL is required")); + } + return trimmed; + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java deleted file mode 100644 index 84c03e8..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducer.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.producer; - -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.model.board.frames.InstagramFrameConfig; -import com.ibrasoft.lensbridge.service.board.BoardContext; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -import java.util.List; - -/** - * Emits the INSTAGRAM frame. It carries no data — the QR destination is read client-side - * from {@code deviceConfig.socialUrl}, which already has its own app-level fallback when - * unset — but declaring it here gives it a real {@code durationInSeconds} and a place in the - * ordered sequence instead of being synthesized and hardcoded in the frontend. Unlike the - * other frame types, this one is not conditional on any backend data: it is always shown. - */ -@Component -@Order(7) -public class InstagramFrameProducer implements FrameProducer { - - @Override - public List produce(BoardContext ctx) { - return List.of(FrameDefinition.builder() - .frameId("instagram") - .frameType(FrameType.INSTAGRAM) - .durationInSeconds(13) - .frameConfig(new InstagramFrameConfig()) - .build()); - } -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java new file mode 100644 index 0000000..fdb3af5 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducer.java @@ -0,0 +1,58 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.PromotableSocialMediaFrameConfig; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Emits one SOCIALS frame per social account promoted to this board's audience. + *

+ * This replaced a single hardcoded Instagram frame whose QR destination was read client-side + * from a since-removed {@code socialUrl} board-config field. Every field a social frame renders — platform, URL, + * copy, duration — is now backend data, so a board can promote several accounts and an + * operator can change any of them without a frontend deploy. + */ +@Component +@RequiredArgsConstructor +@Order(7) +public class SocialsFrameProducer implements FrameProducer { + + private final PromotableSocialMediaService socialMediaService; + + @Override + public List produce(BoardContext ctx) { + List socials = + socialMediaService.getForAudience(ctx.getDevice().getAudience()); + + List out = new ArrayList<>(); + + for (PromotableSocialMedia social : socials) { + PromotableSocialMediaFrameConfig config = PromotableSocialMediaFrameConfig.builder() + .url(social.getUrl()) + .socialType(social.getType()) + .headerText(social.getHeaderText()) + .heroText(social.getHeroText()) + .handle(social.getHandle()) + .footerText(social.getFooterText()) + .build(); + + out.add(FrameDefinition.builder() + .frameId("social:" + social.getId()) + .frameConfig(config) + .durationInSeconds(social.getDuration()) + .frameType(FrameType.SOCIALS) + .build()); + } + + return out; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example index 04b2560..db567cb 100644 --- a/src/main/resources/application.properties.example +++ b/src/main/resources/application.properties.example @@ -10,11 +10,18 @@ spring.datasource.url=jdbc:sqlite:lensbridge-dev.db spring.datasource.driver-class-name=org.sqlite.JDBC spring.datasource.username= spring.datasource.password= -spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect spring.jpa.properties.hibernate.connection.foreign_keys=true spring.jpa.open-in-view=false +# Schema is owned by Flyway; Hibernate only validates against it. Do not set ddl-auto=update +# here -- it would let Hibernate patch a schema Flyway believes it controls, and the two would +# disagree about what has been applied. Migrations are per-vendor because dev is SQLite and +# prod is Postgres; see docs/MIGRATIONS.md. +spring.jpa.hibernate.ddl-auto=validate +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration/{vendor} + # Allowed CORS origins frontend.baseurl=http://localhost:5173 musallahboard.baseurl=http://localhost:3000 diff --git a/src/main/resources/db/migration/postgresql/V1__initial_schema.sql b/src/main/resources/db/migration/postgresql/V1__initial_schema.sql new file mode 100644 index 0000000..5740a88 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V1__initial_schema.sql @@ -0,0 +1,50 @@ +-- +-- V1: the whole schema, as of the move from Hibernate ddl-auto to Flyway. +-- +-- Generated from the JPA entity model rather than written by hand, so it is exactly what +-- `spring.jpa.hibernate.ddl-auto=validate` expects on startup. docs/MIGRATIONS.md has the +-- regeneration command -- though regenerating only makes sense while V1 is the only +-- migration. Once this has run anywhere, editing it fails Flyway's checksum: add a V2. +-- +-- Postgres specifics: native uuid, `timestamp(6) with time zone`, bytea, and the 12 foreign +-- keys SQLite cannot express. The FK constraint names are deliberate rather than Hibernate's +-- generated FK<32 hex chars> -- those are a hash of the mapping, so they change when the +-- mapping does, and a migration that has already run cannot go back and rename them. +-- Hibernate's validate does not check constraint names, so naming them costs nothing. + +create table audit_events (timestamp timestamp(6) with time zone not null, admin_id uuid not null, id uuid not null, target_entity_id uuid, details varchar(512), action varchar(255) not null check (action in ('APPROVE_UPLOAD','UNAPPROVE_UPLOAD','DELETE_UPLOAD','FEATURE_UPLOAD','UNFEATURE_UPLOAD','CREATE_EVENT','UPDATE_EVENT','DELETE_EVENT','CREATE_CALENDAR_EVENT','UPDATE_CALENDAR_EVENT','DELETE_CALENDAR_EVENT','CREATE_POSTER','UPDATE_POSTER','DELETE_POSTER','CREATE_SOCIAL','UPDATE_SOCIAL','DELETE_SOCIAL','SAVE_WEEKLY_CONTENT','DELETE_WEEKLY_CONTENT','UPDATE_BOARD_CONFIG','UPDATE_BOARD_TICKER','REFRESH_BOARDS','ISSUE_ENROLLMENT_TOKEN','REVOKE_DEVICE','ISSUE_DEVICE_COMMAND','PROMOTE_USER','DEMOTE_USER','DISABLE_USER','ENABLE_USER','VIEW_AUDIT_LOGS','EXPORT_DATA','SYSTEM_MAINTENANCE','VERIFY_USER','UNVERIFY_USER','RESET_USER_PASSWORD','TRIGGER_PASSWORD_RESET_EMAIL','ADD_USER_ROLE','REMOVE_USER_ROLE','GRANT_PERMISSION','REVOKE_PERMISSION','ADD_USER','REMOVE_USER','UPDATE_USER')), ip_address varchar(255), target_entity_type varchar(255) check (target_entity_type in ('USER','UPLOAD','MUSALLAH_BOARD','DEVICE','EVENT')), user_agent varchar(255), primary key (id)); +create table board_config_messages (device_id uuid not null, message varchar(255)); +create table board_configs (agenda_duration_seconds integer, dark_mode_after_isha boolean not null, enable_scrolling_message boolean not null, latitude float(53), longitude float(53), next_prayer_duration_seconds integer, device_id uuid not null, city varchar(255), country varchar(255), method varchar(255) check (method in ('KARACHI','ISNA','MWL','MAKKAH','EGYPT','TEHRAN','GULF','KUWAIT','QATAR','SINGAPORE','FRANCE','TURKEY','RUSSIA','DUBAI')), timezone varchar(255), primary key (device_id)); +create table board_events (all_day boolean not null, end_time timestamp(6) with time zone not null, start_time timestamp(6) with time zone not null, id uuid not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), description TEXT, location varchar(255), name varchar(255) not null, primary key (id)); +create table device_commands (deadline_ms integer, acked_at timestamp(6) with time zone, delivered_at timestamp(6) with time zone, expires_at timestamp(6) with time zone, finished_at timestamp(6) with time zone, issued_at timestamp(6) with time zone not null, started_at timestamp(6) with time zone, device_id uuid not null, id uuid not null, error_message TEXT, issued_by varchar(255) not null, kind varchar(255) not null, output_json TEXT, payload_json TEXT, status varchar(255) not null check (status in ('PENDING','DELIVERED','ACKED','RUNNING','SUCCEEDED','FAILED','TIMEOUT','REJECTED','EXPIRED')), primary key (id)); +create table device_telemetry (cpu_tempc float(53), disk_used_pct integer, kiosk_alive boolean, mem_total_mb integer, mem_used_mb integer, recorded_at timestamp(6) with time zone not null, uptime_sec bigint, device_id uuid not null, id uuid not null, displayed_frame_key varchar(64), ipv4 varchar(255), throttle_flags varchar(255), wifi_ssid varchar(255), primary key (id)); +create table devices (enrolled_at timestamp(6) with time zone, last_heartbeat timestamp(6) with time zone, revoked_at timestamp(6) with time zone, id uuid not null, public_key bytea, agent_version varchar(255), audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), display_name varchar(255) not null, hardware_model varchar(255), last_seen_ip varchar(255), organization_id varchar(255) not null, primary key (id)); +create table enrollment_tokens (consumed_at timestamp(6) with time zone, created_at timestamp(6) with time zone not null, expires_at timestamp(6) with time zone not null, consumed_by_device_id uuid, id uuid not null, token_hash bytea not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), created_by varchar(255) not null, display_name varchar(255) not null, primary key (id), constraint idx_enrollment_tokens_hash unique (token_hash)); +create table events (date timestamp(6) with time zone not null, id uuid not null, name varchar(255) not null, status varchar(255) not null check (status in ('UPCOMING','ONGOING','PAST')), primary key (id)); +create table islamic_quotes (id uuid not null, weekly_content_id uuid not null, arabic varchar(255), kind varchar(255) check (kind in ('VERSE','HADITH')), reference varchar(255), translation varchar(255), transliteration varchar(255), primary key (id)); +create table jummah_prayers (prayer_time time(6), slot_order integer, id uuid not null, weekly_content_id uuid not null, khatib varchar(255), room varchar(255), primary key (id)); +create table posters (duration integer not null, end_time timestamp(6) with time zone not null, start_time timestamp(6) with time zone not null, id uuid not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), image varchar(255) not null, signup_url varchar(255), title varchar(255) not null, primary key (id)); +create table promotable_social_media (duration integer not null, id uuid not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), footer_text varchar(255) not null, handle varchar(255), header_text varchar(255) not null, hero_text varchar(255) not null, name varchar(255) not null, type varchar(255) not null check (type in ('INSTAGRAM','YOUTUBE','TIKTOK','WHATSAPP','OTHER')), url varchar(255) not null, primary key (id)); +create table refresh_tokens (revoked boolean not null, created_date timestamp(6) with time zone not null, expiry_date timestamp(6) with time zone not null, last_used_date timestamp(6) with time zone not null, id uuid not null, user_id uuid not null, token_hash varchar(255) not null unique, primary key (id)); +create table uploads (approved boolean not null, featured boolean not null, is_anon boolean not null, created_date timestamp(6) with time zone, deleted_at timestamp(6) with time zone, deleted_by_id uuid, event_id uuid, uploaded_by uuid not null, uuid uuid not null, content_type varchar(255) check (content_type in ('IMAGE','VIDEO','AUDIO','DOCUMENT')), file_name varchar(255), file_url varchar(255), instagram_handle varchar(255), thumbnail_url varchar(255), upload_description varchar(255), primary key (uuid)); +create table user_permissions (user_id uuid not null, permission varchar(255) not null check (permission in ('MEDIA_UPLOAD_SELF','MEDIA_UPLOAD_MODERATE','MEDIA_UPLOAD_READ','MEDIA_EVENT_WRITE','BOARD_CONTENT_READ','BOARD_POSTER_WRITE','BOARD_EVENT_WRITE','BOARD_SOCIAL_WRITE','BOARD_WEEKLY_WRITE','BOARD_TICKER_WRITE','BOARD_CONFIG_READ','BOARD_CONFIG_WRITE','BOARD_REFRESH','BOARD_DEVICE_READ','BOARD_DEVICE_ENROLL','BOARD_DEVICE_REVOKE','BOARD_TELEMETRY_SUBSCRIBE','BOARD_COMMAND_BENIGN','BOARD_COMMAND_DISRUPTIVE','BOARD_COMMAND_INSPECT','IAM_USER_READ','IAM_USER_WRITE','IAM_ROLE_GRANT','AUDIT_READ')), primary key (user_id, permission)); +create table user_roles (user_id uuid not null, role varchar(255) not null check (role in ('USER','ADMIN','BOARD_VIEWER','BOARD_EDITOR','BOARD_ADMIN','ROOT')), primary key (user_id, role)); +create table users (verified_at timestamp(6) with time zone, student_number varchar(10) not null unique, id uuid not null, email varchar(254) not null unique, first_name varchar(255) not null, last_name varchar(255) not null, password_hash varchar(255) not null, primary key (id)); +create table verification_tokens (created_at timestamp(6) with time zone, expires_at timestamp(6) with time zone, used_at timestamp(6) with time zone, id uuid not null, user_id uuid not null, token_hash varchar(255) not null unique, type varchar(255) not null check (type in ('EMAIL_VERIFICATION','PASSWORD_RESET')), primary key (id)); +create table weekly_content (week_number integer not null, year integer not null, id uuid not null, primary key (id), unique (year, week_number)); +create index idx_device_commands_device_status on device_commands (device_id, status); +create index idx_device_commands_pending on device_commands (device_id, issued_at); +create index idx_device_telemetry_device_recorded on device_telemetry (device_id, recorded_at); +create index idx_users_email on users (email); +alter table audit_events add constraint fk_audit_events_admin_id foreign key (admin_id) references users; +alter table board_config_messages add constraint fk_board_config_messages_device_id foreign key (device_id) references board_configs; +alter table board_configs add constraint fk_board_configs_device_id foreign key (device_id) references devices; +alter table islamic_quotes add constraint fk_islamic_quotes_weekly_content_id foreign key (weekly_content_id) references weekly_content; +alter table jummah_prayers add constraint fk_jummah_prayers_weekly_content_id foreign key (weekly_content_id) references weekly_content; +alter table refresh_tokens add constraint fk_refresh_tokens_user_id foreign key (user_id) references users; +alter table uploads add constraint fk_uploads_deleted_by_id foreign key (deleted_by_id) references users; +alter table uploads add constraint fk_uploads_event_id foreign key (event_id) references events; +alter table uploads add constraint fk_uploads_uploaded_by foreign key (uploaded_by) references users; +alter table user_permissions add constraint fk_user_permissions_user_id foreign key (user_id) references users; +alter table user_roles add constraint fk_user_roles_user_id foreign key (user_id) references users; +alter table verification_tokens add constraint fk_verification_tokens_user_id foreign key (user_id) references users; diff --git a/src/main/resources/db/migration/sqlite/V1__initial_schema.sql b/src/main/resources/db/migration/sqlite/V1__initial_schema.sql new file mode 100644 index 0000000..7ca03e3 --- /dev/null +++ b/src/main/resources/db/migration/sqlite/V1__initial_schema.sql @@ -0,0 +1,47 @@ +-- +-- V1: the whole schema, as of the move from Hibernate ddl-auto to Flyway. +-- +-- Generated from the JPA entity model rather than written by hand, so it is exactly what +-- `spring.jpa.hibernate.ddl-auto=validate` expects on startup. docs/MIGRATIONS.md has the +-- regeneration command -- though regenerating only makes sense while V1 is the only +-- migration. Once this has run anywhere, editing it fails Flyway's checksum: add a V2. +-- +-- SQLite specifics: +-- * UUIDs are blob, timestamps are naive `timestamp` -- this is what the SQLite dialect +-- emits, and validate is checked against it. +-- * No foreign keys. SQLite cannot ALTER TABLE ADD CONSTRAINT, so Hibernate never emitted +-- them here either; prod (Postgres) does have all 12. Dev will therefore accept an +-- orphaned row that prod rejects. +-- * The two unique indexes at the bottom are added by hand. The SQLite dialect drops the +-- table-level unique constraints that Postgres gets, which would let dev accept a +-- duplicate enrollment-token hash or two rows for the same week -- both of which the +-- application assumes cannot exist. + +create table audit_events (timestamp timestamp not null, admin_id blob not null, id blob not null, target_entity_id blob, details varchar(512), action varchar(255) not null check (action in ('APPROVE_UPLOAD','UNAPPROVE_UPLOAD','DELETE_UPLOAD','FEATURE_UPLOAD','UNFEATURE_UPLOAD','CREATE_EVENT','UPDATE_EVENT','DELETE_EVENT','CREATE_CALENDAR_EVENT','UPDATE_CALENDAR_EVENT','DELETE_CALENDAR_EVENT','CREATE_POSTER','UPDATE_POSTER','DELETE_POSTER','CREATE_SOCIAL','UPDATE_SOCIAL','DELETE_SOCIAL','SAVE_WEEKLY_CONTENT','DELETE_WEEKLY_CONTENT','UPDATE_BOARD_CONFIG','UPDATE_BOARD_TICKER','REFRESH_BOARDS','ISSUE_ENROLLMENT_TOKEN','REVOKE_DEVICE','ISSUE_DEVICE_COMMAND','PROMOTE_USER','DEMOTE_USER','DISABLE_USER','ENABLE_USER','VIEW_AUDIT_LOGS','EXPORT_DATA','SYSTEM_MAINTENANCE','VERIFY_USER','UNVERIFY_USER','RESET_USER_PASSWORD','TRIGGER_PASSWORD_RESET_EMAIL','ADD_USER_ROLE','REMOVE_USER_ROLE','GRANT_PERMISSION','REVOKE_PERMISSION','ADD_USER','REMOVE_USER','UPDATE_USER')), ip_address varchar(255), target_entity_type varchar(255) check (target_entity_type in ('USER','UPLOAD','MUSALLAH_BOARD','DEVICE','EVENT')), user_agent varchar(255), primary key (id)); +create table board_config_messages (device_id blob not null, message varchar(255)); +create table board_configs (agenda_duration_seconds integer, dark_mode_after_isha boolean not null, enable_scrolling_message boolean not null, latitude float, longitude float, next_prayer_duration_seconds integer, device_id blob not null, city varchar(255), country varchar(255), method varchar(255) check (method in ('KARACHI','ISNA','MWL','MAKKAH','EGYPT','TEHRAN','GULF','KUWAIT','QATAR','SINGAPORE','FRANCE','TURKEY','RUSSIA','DUBAI')), timezone varchar(255), primary key (device_id)); +create table board_events (all_day boolean not null, end_time timestamp not null, start_time timestamp not null, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), description TEXT, location varchar(255), name varchar(255) not null, primary key (id)); +create table device_commands (deadline_ms integer, acked_at timestamp, delivered_at timestamp, expires_at timestamp, finished_at timestamp, issued_at timestamp not null, started_at timestamp, id blob not null, device_id blob not null, error_message TEXT, issued_by varchar(255) not null, kind varchar(255) not null, output_json TEXT, payload_json TEXT, status varchar(255) not null check (status in ('PENDING','DELIVERED','ACKED','RUNNING','SUCCEEDED','FAILED','TIMEOUT','REJECTED','EXPIRED')), primary key (id)); +create table device_telemetry (cpu_tempc float, disk_used_pct integer, kiosk_alive boolean, mem_total_mb integer, mem_used_mb integer, recorded_at timestamp not null, uptime_sec bigint, id blob not null, displayed_frame_key varchar(64), device_id blob not null, ipv4 varchar(255), throttle_flags varchar(255), wifi_ssid varchar(255), primary key (id)); +create table devices (enrolled_at timestamp, last_heartbeat timestamp, revoked_at timestamp, id blob not null, agent_version varchar(255), audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), display_name varchar(255) not null, hardware_model varchar(255), last_seen_ip varchar(255), organization_id varchar(255) not null, public_key blob, primary key (id)); +create table enrollment_tokens (consumed_at timestamp, created_at timestamp not null, expires_at timestamp not null, consumed_by_device_id blob, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), created_by varchar(255) not null, display_name varchar(255) not null, token_hash blob not null, primary key (id)); +create table events (date timestamp not null, id blob not null, name varchar(255) not null, status varchar(255) not null check (status in ('UPCOMING','ONGOING','PAST')), primary key (id)); +create table islamic_quotes (id blob not null, weekly_content_id blob not null, arabic varchar(255), kind varchar(255) check (kind in ('VERSE','HADITH')), reference varchar(255), translation varchar(255), transliteration varchar(255), primary key (id)); +create table jummah_prayers (prayer_time time(6), slot_order integer, id blob not null, weekly_content_id blob not null, khatib varchar(255), room varchar(255), primary key (id)); +create table posters (duration integer not null, end_time timestamp not null, start_time timestamp not null, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), image varchar(255) not null, signup_url varchar(255), title varchar(255) not null, primary key (id)); +create table promotable_social_media (duration integer not null, id blob not null, audience varchar(255) not null check (audience in ('BROTHERS','SISTERS','BOTH')), footer_text varchar(255) not null, handle varchar(255), header_text varchar(255) not null, hero_text varchar(255) not null, name varchar(255) not null, type varchar(255) not null check (type in ('INSTAGRAM','YOUTUBE','TIKTOK','WHATSAPP','OTHER')), url varchar(255) not null, primary key (id)); +create table refresh_tokens (revoked boolean not null, created_date timestamp not null, expiry_date timestamp not null, last_used_date timestamp not null, id blob not null, user_id blob not null, token_hash varchar(255) not null unique, primary key (id)); +create table uploads (approved boolean not null, featured boolean not null, is_anon boolean not null, created_date timestamp, deleted_at timestamp, deleted_by_id blob, event_id blob, uploaded_by blob not null, uuid blob not null, content_type varchar(255) check (content_type in ('IMAGE','VIDEO','AUDIO','DOCUMENT')), file_name varchar(255), file_url varchar(255), instagram_handle varchar(255), thumbnail_url varchar(255), upload_description varchar(255), primary key (uuid)); +create table user_permissions (user_id blob not null, permission varchar(255) not null check (permission in ('MEDIA_UPLOAD_SELF','MEDIA_UPLOAD_MODERATE','MEDIA_UPLOAD_READ','MEDIA_EVENT_WRITE','BOARD_CONTENT_READ','BOARD_POSTER_WRITE','BOARD_EVENT_WRITE','BOARD_SOCIAL_WRITE','BOARD_WEEKLY_WRITE','BOARD_TICKER_WRITE','BOARD_CONFIG_READ','BOARD_CONFIG_WRITE','BOARD_REFRESH','BOARD_DEVICE_READ','BOARD_DEVICE_ENROLL','BOARD_DEVICE_REVOKE','BOARD_TELEMETRY_SUBSCRIBE','BOARD_COMMAND_BENIGN','BOARD_COMMAND_DISRUPTIVE','BOARD_COMMAND_INSPECT','IAM_USER_READ','IAM_USER_WRITE','IAM_ROLE_GRANT','AUDIT_READ')), primary key (user_id, permission)); +create table user_roles (user_id blob not null, role varchar(255) not null check (role in ('USER','ADMIN','BOARD_VIEWER','BOARD_EDITOR','BOARD_ADMIN','ROOT')), primary key (user_id, role)); +create table users (verified_at timestamp, student_number varchar(10) not null unique, id blob not null, email varchar(254) not null unique, first_name varchar(255) not null, last_name varchar(255) not null, password_hash varchar(255) not null, primary key (id)); +create table verification_tokens (created_at timestamp, expires_at timestamp, used_at timestamp, id blob not null, user_id blob not null, token_hash varchar(255) not null unique, type varchar(255) not null check (type in ('EMAIL_VERIFICATION','PASSWORD_RESET')), primary key (id)); +create table weekly_content (week_number integer not null, year integer not null, id blob not null, primary key (id)); +create index idx_device_commands_device_status on device_commands (device_id, status); +create index idx_device_commands_pending on device_commands (device_id, issued_at); +create index idx_device_telemetry_device_recorded on device_telemetry (device_id, recorded_at); +create index idx_users_email on users (email); + +-- Uniqueness Postgres enforces via table constraints; see the note above. +create unique index idx_enrollment_tokens_hash on enrollment_tokens (token_hash); +create unique index idx_weekly_content_year_week on weekly_content (year, week_number); diff --git a/src/test/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverterTest.java b/src/test/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverterTest.java new file mode 100644 index 0000000..0c923da --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/config/converter/StringToSocialTypeConverterTest.java @@ -0,0 +1,42 @@ +package com.ibrasoft.lensbridge.config.converter; + +import com.ibrasoft.lensbridge.model.board.SocialType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StringToSocialTypeConverterTest { + + private final StringToSocialTypeConverter converter = new StringToSocialTypeConverter(); + + @Test + void convertsLowercaseLabel() { + assertThat(converter.convert("instagram")).isEqualTo(SocialType.INSTAGRAM); + assertThat(converter.convert("whatsapp")).isEqualTo(SocialType.WHATSAPP); + assertThat(converter.convert("other")).isEqualTo(SocialType.OTHER); + } + + @Test + void convertsEnumName() { + assertThat(converter.convert("TIKTOK")).isEqualTo(SocialType.TIKTOK); + } + + @Test + void convertIsCaseInsensitive() { + assertThat(converter.convert("YouTube")).isEqualTo(SocialType.YOUTUBE); + } + + @Test + void convertThrowsForUnknownType() { + assertThatThrownBy(() -> converter.convert("myspace")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown social type: myspace"); + } + + @Test + void convertThrowsForEmptyString() { + assertThatThrownBy(() -> converter.convert("")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java index 53c9a95..e5e089e 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java @@ -5,6 +5,7 @@ import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; import com.ibrasoft.lensbridge.service.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -44,6 +45,8 @@ class BoardAdminControllerPermissionTest { @MockitoBean private PosterService posterService; @MockitoBean + private PromotableSocialMediaService socialMediaService; + @MockitoBean private BoardService boardService; @MockitoBean private AdminAuditService auditService; @@ -91,6 +94,7 @@ void anonymousIsUnauthorized() throws Exception { void viewerCanReadContentAndConfig() throws Exception { expectAllowed(get(BASE + "/posters"), Role.BOARD_VIEWER); expectAllowed(get(BASE + "/events"), Role.BOARD_VIEWER); + expectAllowed(get(BASE + "/socials"), Role.BOARD_VIEWER); expectAllowed(get(BASE + "/weekly-content"), Role.BOARD_VIEWER); expectAllowed(get(BASE + "/configs"), Role.BOARD_VIEWER); } @@ -99,6 +103,7 @@ void viewerCanReadContentAndConfig() throws Exception { void viewerCannotWriteAnything() throws Exception { expectForbidden(delete(BASE + "/posters/" + id()), Role.BOARD_VIEWER); expectForbidden(delete(BASE + "/events/" + id()), Role.BOARD_VIEWER); + expectForbidden(delete(BASE + "/socials/" + id()), Role.BOARD_VIEWER); expectForbidden(put(BASE + "/weekly-content/2026/1") .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_VIEWER); expectForbidden(patch(BASE + "/configs/" + id()) @@ -112,6 +117,7 @@ void viewerCannotWriteAnything() throws Exception { void editorCanWriteContent() throws Exception { expectAllowed(delete(BASE + "/posters/" + id()), Role.BOARD_EDITOR); expectAllowed(delete(BASE + "/events/" + id()), Role.BOARD_EDITOR); + expectAllowed(delete(BASE + "/socials/" + id()), Role.BOARD_EDITOR); expectAllowed(put(BASE + "/weekly-content/2026/1") .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.BOARD_EDITOR); expectAllowed(post(BASE + "/refresh"), Role.BOARD_EDITOR); @@ -157,6 +163,7 @@ void rootIsAuthorizedEverywhere() throws Exception { expectAllowed(patch(BASE + "/configs/" + id()) .contentType(MediaType.APPLICATION_JSON).content("{}"), Role.ROOT); expectAllowed(delete(BASE + "/posters/" + id()), Role.ROOT); + expectAllowed(delete(BASE + "/socials/" + id()), Role.ROOT); expectAllowed(post(BASE + "/refresh"), Role.ROOT); } } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java index 3180cda..aa53564 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerUpdateDeviceTest.java @@ -8,6 +8,7 @@ import com.ibrasoft.lensbridge.service.AdminAuditService; import com.ibrasoft.lensbridge.service.BoardService; import com.ibrasoft.lensbridge.service.PosterService; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; import com.ibrasoft.lensbridge.service.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -51,6 +52,8 @@ class BoardAdminControllerUpdateDeviceTest { @MockitoBean private PosterService posterService; @MockitoBean + private PromotableSocialMediaService socialMediaService; + @MockitoBean private AdminAuditService auditService; @MockitoBean private BoardStreamHandler boardStreamHandler; diff --git a/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java index 05f60f3..84bed59 100644 --- a/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandlerTest.java @@ -1,15 +1,19 @@ package com.ibrasoft.lensbridge.exception; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.upload.response.DailyLimitErrorResponse; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import lombok.Data; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -33,6 +37,8 @@ class GlobalExceptionHandlerTest { private static final String URL = "/test/echo"; + private static final String CONFLICT_URL = "/test/conflict"; + private static final String PASSTHROUGH_URL = "/test/passthrough"; private MockMvc mockMvc; @@ -48,6 +54,18 @@ static class StubController { MessageResponse echo(@Valid @RequestBody Payload payload) { return new MessageResponse(payload.getName()); } + + @GetMapping(CONFLICT_URL) + MessageResponse conflict() { + throw new ApiResponseException(HttpStatus.CONFLICT, + ErrorResponse.of("That instagram URL is already promoted")); + } + + @GetMapping(PASSTHROUGH_URL) + MessageResponse passthrough() { + throw new ApiResponseException(HttpStatus.TOO_MANY_REQUESTS, + DailyLimitErrorResponse.of("Daily limit reached", 5, 5, "unknown")); + } } @BeforeEach @@ -112,4 +130,33 @@ void aFailedConstraintIsA400NamingTheField() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message", containsString("name:"))); } + + /** + * Services throw with an {@code ErrorResponse}, which serializes as {@code {"error": ...}}, + * but the spec documents every operation's error as {@code MessageResponse} and every + * generated client reads {@code .message}. The mismatch meant each specific message a + * service wrote arrived as {@code undefined} and the operator saw a generic fallback + * instead — the 409 below read "Failed to create social" rather than naming the duplicate. + */ + @Test + void anErrorResponseBodyIsPublishedUnderTheDocumentedMessageKey() throws Exception { + mockMvc.perform(get(CONFLICT_URL)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("That instagram URL is already promoted")) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + /** + * Bodies carrying fields beyond a message keep every one of them — a client showing + * "you have used 5 of 5 uploads today" needs the counts, and flattening to a message + * would leave it with prose to parse. + */ + @Test + void aRicherErrorBodyPassesThroughUntouched() throws Exception { + mockMvc.perform(get(PASSTHROUGH_URL)) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.dailyLimit").value(5)) + .andExpect(jsonPath("$.uploadsToday").value(5)) + .andExpect(jsonPath("$.error").value("Daily limit reached")); + } } diff --git a/src/test/java/com/ibrasoft/lensbridge/migration/MigrationParityTest.java b/src/test/java/com/ibrasoft/lensbridge/migration/MigrationParityTest.java new file mode 100644 index 0000000..43a93c2 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/migration/MigrationParityTest.java @@ -0,0 +1,84 @@ +package com.ibrasoft.lensbridge.migration; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Dev runs on SQLite and prod on Postgres, so every migration has to be written twice — + * {@code db/migration/sqlite} and {@code db/migration/postgresql} — because the dialects' + * DDL is not interchangeable ({@code uuid} vs {@code blob}, {@code bytea} vs {@code blob}, + * {@code timestamp(6) with time zone} vs {@code timestamp}). + *

+ * Nothing about that is enforced by Flyway or the build. A migration added to only one + * directory compiles, passes every other test, and runs fine locally — then either fails on + * deploy or, worse, succeeds and leaves the other environment silently a version behind. + * Since {@code spring.flyway.locations} uses {@code {vendor}}, neither environment can even + * see the other's directory to notice. + *

+ * This is a file-listing test rather than a schema-comparison one: it cannot tell you the two + * scripts describe the same tables, only that you did not forget one. That is the mistake + * worth catching automatically; the rest is review. + */ +class MigrationParityTest { + + private static final String SQLITE = "db/migration/sqlite"; + private static final String POSTGRESQL = "db/migration/postgresql"; + + private static Path dir(String location) throws URISyntaxException { + return Paths.get(Objects.requireNonNull( + MigrationParityTest.class.getClassLoader().getResource(location), + "Missing migration directory: " + location).toURI()); + } + + /** Version-and-description key: {@code V1__initial_schema.sql} -> {@code V1__initial_schema}. */ + private static List migrationsIn(String location) throws IOException, URISyntaxException { + try (Stream files = Files.list(dir(location))) { + return files.map(p -> p.getFileName().toString()) + .filter(n -> n.endsWith(".sql")) + .map(n -> n.substring(0, n.length() - ".sql".length())) + .sorted() + .collect(Collectors.toList()); + } + } + + @Test + void everyMigrationExistsForBothDatabases() throws Exception { + assertThat(migrationsIn(SQLITE)) + .as("sqlite and postgresql must carry the same migrations, by version and description") + .isEqualTo(migrationsIn(POSTGRESQL)); + } + + @Test + void thereIsAtLeastOneMigration() throws Exception { + assertThat(migrationsIn(SQLITE)).isNotEmpty(); + } + + /** + * Two files sharing a version is a deploy-time Flyway error ("Found more than one migration + * with version 2"), and it is easy to produce by branching: two people each add a V2. + */ + @Test + void versionsAreUniqueWithinEachDatabase() throws Exception { + for (String location : List.of(SQLITE, POSTGRESQL)) { + Map byVersion = migrationsIn(location).stream() + .map(name -> name.substring(0, name.indexOf("__"))) + .collect(Collectors.groupingBy(v -> v, Collectors.counting())); + + assertThat(byVersion) + .as("duplicate migration version in %s", location) + .allSatisfy((version, count) -> assertThat(count).isEqualTo(1)); + } + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeJsonTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeJsonTest.java new file mode 100644 index 0000000..ed0b842 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeJsonTest.java @@ -0,0 +1,58 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * SocialType must serialise as the lowercase label, like {@code Audience} and + * {@code FrameType} before it. Without {@code @JsonValue} Jackson publishes + * {@code INSTAGRAM} while the frontend keys its platform icons off {@code instagram}, + * and every socials frame renders unbranded. + */ +class SocialTypeJsonTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void serialisesAsLowercase() throws Exception { + assertEquals("\"instagram\"", mapper.writeValueAsString(SocialType.INSTAGRAM)); + assertEquals("\"whatsapp\"", mapper.writeValueAsString(SocialType.WHATSAPP)); + assertEquals("\"other\"", mapper.writeValueAsString(SocialType.OTHER)); + } + + @Test + void readsTheLowercaseForm() throws Exception { + assertEquals(SocialType.INSTAGRAM, mapper.readValue("\"instagram\"", SocialType.class)); + assertEquals(SocialType.TIKTOK, mapper.readValue("\"tiktok\"", SocialType.class)); + } + + /** Anything already persisted or sent as the enum name must keep binding. */ + @Test + void stillReadsTheUppercaseEnumName() throws Exception { + assertEquals(SocialType.INSTAGRAM, mapper.readValue("\"INSTAGRAM\"", SocialType.class)); + assertEquals(SocialType.YOUTUBE, mapper.readValue("\"YouTube\"", SocialType.class)); + } + + @Test + void rejectsUnknownValues() { + assertThrows(Exception.class, () -> mapper.readValue("\"myspace\"", SocialType.class)); + } + + @Test + void roundTrips() throws Exception { + for (SocialType type : SocialType.values()) { + assertEquals(type, mapper.readValue(mapper.writeValueAsString(type), SocialType.class)); + } + } + + /** The value the spec publishes is the value a client receives. */ + @Test + void serialisedFormMatchesToString() throws Exception { + for (SocialType type : SocialType.values()) { + assertEquals("\"" + type + "\"", mapper.writeValueAsString(type)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeTest.java new file mode 100644 index 0000000..43e59d8 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/SocialTypeTest.java @@ -0,0 +1,45 @@ +package com.ibrasoft.lensbridge.model.board; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class SocialTypeTest { + + @Test + void toStringReturnsLowercaseLabel() { + assertThat(SocialType.INSTAGRAM.toString()).isEqualTo("instagram"); + assertThat(SocialType.YOUTUBE.toString()).isEqualTo("youtube"); + assertThat(SocialType.TIKTOK.toString()).isEqualTo("tiktok"); + assertThat(SocialType.WHATSAPP.toString()).isEqualTo("whatsapp"); + assertThat(SocialType.OTHER.toString()).isEqualTo("other"); + } + + @Test + void toStringDiffersFromEnumName() { + assertThat(SocialType.INSTAGRAM.toString()).isNotEqualTo(SocialType.INSTAGRAM.name()); + } + + @Test + void fromAcceptsEitherCase() { + assertThat(SocialType.from("instagram")).contains(SocialType.INSTAGRAM); + assertThat(SocialType.from("INSTAGRAM")).contains(SocialType.INSTAGRAM); + assertThat(SocialType.from("InStAgRaM")).contains(SocialType.INSTAGRAM); + } + + @Test + void fromReturnsEmptyForNullAndUnknown() { + assertThat(SocialType.from(null)).isEmpty(); + assertThat(SocialType.from("myspace")).isEmpty(); + } + + /** Pinned against the enum so a new platform cannot skip a label. */ + @Test + void everyValueRoundTripsThroughItsLabel() { + for (SocialType type : SocialType.values()) { + assertThat(SocialType.from(type.toString())).isEqualTo(Optional.of(type)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfigDiscriminatorTest.java b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfigDiscriminatorTest.java new file mode 100644 index 0000000..16debb1 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/model/board/frames/FrameConfigDiscriminatorTest.java @@ -0,0 +1,70 @@ +package com.ibrasoft.lensbridge.model.board.frames; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link FrameConfig} declares its subtypes twice: once for Jackson, which decides what the + * server actually sends, and once for springdoc, which decides what {@code openapi.yaml} tells + * clients the server sends. Nothing in the compiler makes the two agree. + *

+ * They disagreed once already. The {@code @Schema} mapping was absent entirely, so the spec + * emitted a discriminator with no mapping, OpenAPI's implicit fallback is the schema name, and + * every generated TypeScript frame config claimed {@code type: "PosterFrameConfig"} for a field + * that carries {@code "poster"}. Nothing failed loudly — the board dispatches on + * {@code frameType} — it just meant no client could narrow on the discriminator. + */ +class FrameConfigDiscriminatorTest { + + private static Map> jacksonSubtypes() { + return Stream.of(FrameConfig.class.getAnnotation(JsonSubTypes.class).value()) + .collect(Collectors.toMap(JsonSubTypes.Type::name, JsonSubTypes.Type::value)); + } + + private static Map> springdocSubtypes() { + return Stream.of(FrameConfig.class.getAnnotation(Schema.class).discriminatorMapping()) + .collect(Collectors.toMap(DiscriminatorMapping::value, DiscriminatorMapping::schema)); + } + + @Test + void theSpecMappingMatchesWhatJacksonActuallySends() { + assertThat(springdocSubtypes()).isEqualTo(jacksonSubtypes()); + } + + @Test + void bothAgreeOnTheDiscriminatorPropertyName() { + assertThat(FrameConfig.class.getAnnotation(Schema.class).discriminatorProperty()) + .isEqualTo(FrameConfig.class + .getAnnotation(com.fasterxml.jackson.annotation.JsonTypeInfo.class) + .property()); + } + + /** + * The wire value is the thing both mappings claim it is. Pinned by serializing a real + * instance rather than by re-reading the annotation, so this fails if Jackson's behaviour + * ever diverges from its own declaration. + */ + @Test + void aSerialisedSocialsConfigCarriesTheMappedDiscriminator() throws Exception { + String json = new ObjectMapper().writeValueAsString( + PromotableSocialMediaFrameConfig.builder().url("https://example.com").build()); + + assertThat(json).contains("\"type\":\"socials\""); + } + + /** Every subtype the spec advertises must really extend the base it is mapped under. */ + @Test + void everyMappedSchemaIsAFrameConfig() { + assertThat(springdocSubtypes().values()).allSatisfy( + type -> assertThat(FrameConfig.class).isAssignableFrom(type)); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaServiceTest.java new file mode 100644 index 0000000..0dd4f18 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/PromotableSocialMediaServiceTest.java @@ -0,0 +1,246 @@ +package com.ibrasoft.lensbridge.service; + +import com.ibrasoft.lensbridge.dto.board.request.CreatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.dto.board.request.UpdatePromotableSocialMediaRequest; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.handler.BoardStreamHandler; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.SocialType; +import com.ibrasoft.lensbridge.repository.sql.PromotableSocialMediaRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PromotableSocialMediaServiceTest { + + @Mock + private PromotableSocialMediaRepository repository; + + @Mock + private BoardStreamHandler boardStream; + + @InjectMocks + private PromotableSocialMediaService service; + + private PromotableSocialMedia social(String name) { + return PromotableSocialMedia.builder() + .id(UUID.randomUUID()) + .name(name) + .duration(13) + .audience(Audience.BOTH) + .type(SocialType.INSTAGRAM) + .url("https://instagram.com/utmmsa") + .headerText("follow along") + .heroText("your home on campus") + .handle("@utmmsa") + .footerText("Event recaps, announcements, and more!") + .build(); + } + + private CreatePromotableSocialMediaRequest.CreatePromotableSocialMediaRequestBuilder createRequest() { + return CreatePromotableSocialMediaRequest.builder() + .name("MSA Instagram") + .duration(13) + .audience(Audience.BOTH) + .type(SocialType.INSTAGRAM) + .url("https://instagram.com/utmmsa") + .headerText("follow along") + .heroText("your home on campus") + .handle("@utmmsa") + .footerText("Event recaps, announcements, and more!"); + } + + // ==================== Read paths ==================== + + @Test + void getAllDelegatesToRepositorySortedByName() { + List expected = List.of(social("a"), social("b")); + when(repository.findAllByOrderByNameAsc()).thenReturn(expected); + + assertThat(service.getAll()).isEqualTo(expected); + } + + @Test + void getByIdReturnsEntity() { + PromotableSocialMedia s = social("a"); + when(repository.findById(s.getId())).thenReturn(Optional.of(s)); + + assertThat(service.getById(s.getId())).isSameAs(s); + } + + @Test + void getByIdThrowsNotFoundWhenMissing() { + UUID id = UUID.randomUUID(); + when(repository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getById(id)) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void getForAudienceFiltersByAudienceOrBoth() { + List expected = List.of(social("a")); + when(repository.findByAudienceOrBoth(Audience.SISTERS)).thenReturn(expected); + + assertThat(service.getForAudience(Audience.SISTERS)).isEqualTo(expected); + } + + @Test + void getByTypeDelegatesToRepository() { + List expected = List.of(social("a")); + when(repository.findByTypeOrderByNameAsc(SocialType.WHATSAPP)).thenReturn(expected); + + assertThat(service.getByType(SocialType.WHATSAPP)).isEqualTo(expected); + } + + // ==================== create ==================== + + @Test + void createPersistsAllFieldsAndNotifiesBoards() { + when(repository.save(any(PromotableSocialMedia.class))).thenAnswer(inv -> inv.getArgument(0)); + + PromotableSocialMedia created = service.create(createRequest().build()); + + assertThat(created.getName()).isEqualTo("MSA Instagram"); + assertThat(created.getType()).isEqualTo(SocialType.INSTAGRAM); + assertThat(created.getUrl()).isEqualTo("https://instagram.com/utmmsa"); + assertThat(created.getDuration()).isEqualTo(13); + assertThat(created.getAudience()).isEqualTo(Audience.BOTH); + assertThat(created.getHandle()).isEqualTo("@utmmsa"); + verify(boardStream).contentChanged("socials"); + } + + @Test + void createTrimsUrlBeforePersisting() { + when(repository.save(any(PromotableSocialMedia.class))).thenAnswer(inv -> inv.getArgument(0)); + + PromotableSocialMedia created = service.create( + createRequest().url(" https://instagram.com/utmmsa ").build()); + + assertThat(created.getUrl()).isEqualTo("https://instagram.com/utmmsa"); + } + + @Test + void createStoresBlankHandleAsNull() { + when(repository.save(any(PromotableSocialMedia.class))).thenAnswer(inv -> inv.getArgument(0)); + + PromotableSocialMedia created = service.create(createRequest().handle(" ").build()); + + assertThat(created.getHandle()).isNull(); + } + + @Test + void createRejectsBlankUrl() { + assertThatThrownBy(() -> service.create(createRequest().url(" ").build())) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(repository, never()).save(any()); + } + + @Test + void createRejectsDuplicateUrlForSameType() { + when(repository.existsByTypeAndUrl(SocialType.INSTAGRAM, "https://instagram.com/utmmsa")) + .thenReturn(true); + + assertThatThrownBy(() -> service.create(createRequest().build())) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.CONFLICT)); + verify(repository, never()).save(any()); + } + + // ==================== update ==================== + + @Test + void updateAppliesOnlyNonNullFields() { + PromotableSocialMedia existing = social("Old"); + when(repository.findById(existing.getId())).thenReturn(Optional.of(existing)); + when(repository.save(any(PromotableSocialMedia.class))).thenAnswer(inv -> inv.getArgument(0)); + + PromotableSocialMedia updated = service.update(existing.getId(), + UpdatePromotableSocialMediaRequest.builder().heroText("a new home").build()); + + assertThat(updated.getHeroText()).isEqualTo("a new home"); + assertThat(updated.getName()).isEqualTo("Old"); // unchanged + assertThat(updated.getDuration()).isEqualTo(13); // unchanged + assertThat(updated.getAudience()).isEqualTo(Audience.BOTH); // unchanged + assertThat(updated.getHandle()).isEqualTo("@utmmsa"); // unchanged + verify(boardStream).contentChanged("socials"); + } + + @Test + void updateWithEmptyHandleClearsIt() { + PromotableSocialMedia existing = social("Old"); + when(repository.findById(existing.getId())).thenReturn(Optional.of(existing)); + when(repository.save(any(PromotableSocialMedia.class))).thenAnswer(inv -> inv.getArgument(0)); + + PromotableSocialMedia updated = service.update(existing.getId(), + UpdatePromotableSocialMediaRequest.builder().handle("").build()); + + assertThat(updated.getHandle()).isNull(); + } + + @Test + void updateRejectsBlankUrl() { + PromotableSocialMedia existing = social("Old"); + when(repository.findById(existing.getId())).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.update(existing.getId(), + UpdatePromotableSocialMediaRequest.builder().url(" ").build())) + .isInstanceOf(ApiResponseException.class) + .satisfies(e -> assertThat(((ApiResponseException) e).getStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(repository, never()).save(any()); + } + + @Test + void updateThrowsWhenNotFound() { + UUID id = UUID.randomUUID(); + when(repository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.update(id, + UpdatePromotableSocialMediaRequest.builder().build())) + .isInstanceOf(ApiResponseException.class); + } + + // ==================== delete ==================== + + @Test + void deleteRemovesEntityAndNotifiesBoards() { + PromotableSocialMedia existing = social("Old"); + when(repository.findById(existing.getId())).thenReturn(Optional.of(existing)); + + service.delete(existing.getId()); + + verify(repository).delete(existing); + verify(boardStream).contentChanged("socials"); + } + + @Test + void deleteThrowsWhenNotFound() { + UUID id = UUID.randomUUID(); + when(repository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.delete(id)).isInstanceOf(ApiResponseException.class); + verify(repository, never()).delete(any(PromotableSocialMedia.class)); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java deleted file mode 100644 index 5c34c89..0000000 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/InstagramFrameProducerTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.producer; - -import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.model.board.frames.InstagramFrameConfig; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class InstagramFrameProducerTest { - - private final InstagramFrameProducer producer = new InstagramFrameProducer(); - - @Test - void produceReturnsOneMarkerFrameRegardlessOfContext() { - List frames = producer.produce(null); - - assertThat(frames).hasSize(1); - FrameDefinition def = frames.get(0); - assertThat(def.getFrameId()).isEqualTo("instagram"); - assertThat(def.getFrameType()).isEqualTo(FrameType.INSTAGRAM); - assertThat(def.getDurationInSeconds()).isEqualTo(13); - assertThat(def.getFrameConfig()).isInstanceOf(InstagramFrameConfig.class); - } -} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducerTest.java new file mode 100644 index 0000000..aea8200 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/SocialsFrameProducerTest.java @@ -0,0 +1,115 @@ +package com.ibrasoft.lensbridge.service.board.producer; + +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.PromotableSocialMedia; +import com.ibrasoft.lensbridge.model.board.SocialType; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.model.board.frames.PromotableSocialMediaFrameConfig; +import com.ibrasoft.lensbridge.service.PromotableSocialMediaService; +import com.ibrasoft.lensbridge.service.board.BoardContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SocialsFrameProducerTest { + + @Mock + private PromotableSocialMediaService socialMediaService; + + @InjectMocks + private SocialsFrameProducer producer; + + private BoardContext ctx(Audience audience) { + Device device = Device.builder() + .id(UUID.randomUUID()) + .displayName("Musallah A") + .audience(audience) + .build(); + return BoardContext.builder() + .device(device) + .now(ZonedDateTime.now(ZoneId.of("America/Toronto"))) + .build(); + } + + private PromotableSocialMedia social(SocialType type, String url, int duration) { + return PromotableSocialMedia.builder() + .id(UUID.randomUUID()) + .name(type + " page") + .duration(duration) + .audience(Audience.BOTH) + .type(type) + .url(url) + .headerText("follow along") + .heroText("your home on campus") + .handle("@utmmsa") + .footerText("Event recaps, announcements, and more!") + .build(); + } + + @Test + void producesOneFrameEachMappingEveryConfigField() { + PromotableSocialMedia ig = social(SocialType.INSTAGRAM, "https://instagram.com/utmmsa", 13); + when(socialMediaService.getForAudience(Audience.SISTERS)).thenReturn(List.of(ig)); + + List frames = producer.produce(ctx(Audience.SISTERS)); + + assertThat(frames).hasSize(1); + FrameDefinition def = frames.get(0); + assertThat(def.getFrameId()).isEqualTo("social:" + ig.getId()); + assertThat(def.getFrameType()).isEqualTo(FrameType.SOCIALS); + assertThat(def.getDurationInSeconds()).isEqualTo(13); + assertThat(def.getFrameConfig()).isInstanceOf(PromotableSocialMediaFrameConfig.class); + + PromotableSocialMediaFrameConfig config = (PromotableSocialMediaFrameConfig) def.getFrameConfig(); + assertThat(config.getSocialType()).isEqualTo(SocialType.INSTAGRAM); + assertThat(config.getUrl()).isEqualTo("https://instagram.com/utmmsa"); + assertThat(config.getHeaderText()).isEqualTo("follow along"); + assertThat(config.getHeroText()).isEqualTo("your home on campus"); + assertThat(config.getHandle()).isEqualTo("@utmmsa"); + assertThat(config.getFooterText()).isEqualTo("Event recaps, announcements, and more!"); + } + + @Test + void producesAFrameForEverySocialInRepositoryOrder() { + PromotableSocialMedia ig = social(SocialType.INSTAGRAM, "https://instagram.com/utmmsa", 13); + PromotableSocialMedia wa = social(SocialType.WHATSAPP, "https://chat.whatsapp.com/x", 9); + when(socialMediaService.getForAudience(Audience.BROTHERS)).thenReturn(List.of(ig, wa)); + + List frames = producer.produce(ctx(Audience.BROTHERS)); + + assertThat(frames).extracting(FrameDefinition::getFrameId) + .containsExactly("social:" + ig.getId(), "social:" + wa.getId()); + assertThat(frames).extracting(FrameDefinition::getDurationInSeconds) + .containsExactly(13, 9); + } + + @Test + void queriesUsingTheDevicesOwnAudience() { + when(socialMediaService.getForAudience(Audience.SISTERS)).thenReturn(List.of()); + + producer.produce(ctx(Audience.SISTERS)); + + verify(socialMediaService).getForAudience(Audience.SISTERS); + } + + @Test + void producesNothingWhenNoSocialsArePromoted() { + when(socialMediaService.getForAudience(Audience.BOTH)).thenReturn(List.of()); + + assertThat(producer.produce(ctx(Audience.BOTH))).isEmpty(); + } +} From 3a9ed399d6c6eddcd8893c7be559a9f60e735cf5 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 22:11:09 -0400 Subject: [PATCH 43/52] Allow empty agenda frames to be sent --- .../lensbridge/service/board/producer/AgendaFrameProducer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java index 8760e5b..16ab24b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java @@ -40,7 +40,7 @@ public List produce(BoardContext ctx) { // An agenda reading "nothing scheduled" for seven straight days is worse than no agenda // at all. Both frames this replaced skipped themselves when empty; so does this one. - if (events.isEmpty()) return List.of(); +// if (events.isEmpty()) return List.of(); return List.of(transform(events, ctx)); } From 07176e8fe41472a59d270bd097e530d818a5edf1 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 22:13:27 -0400 Subject: [PATCH 44/52] update statics to be consistent with rest of project --- .../service/board/producer/WeeklyContentFrameProducer.java | 4 ++-- .../board/producer/WeeklyContentFrameProducerTest.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java index 411ac3a..02e50d2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java @@ -32,7 +32,7 @@ public class WeeklyContentFrameProducer implements FrameProducer { private final BoardService boardService; - private final static String JUMUAH_FRAME_ID = "jumuah"; + private final static String JUMMAH_FRAME_ID = "jummah"; private final static String QUOTE_FRAME_ID = "quote:"; @Override @@ -90,7 +90,7 @@ private FrameDefinition jummahFrame(List prayers) { .build(); return FrameDefinition.builder() - .frameId(JUMUAH_FRAME_ID) + .frameId(JUMMAH_FRAME_ID) .frameType(FrameType.JUMMAH) .durationInSeconds(null) .frameConfig(config) diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java index 141b7be..b8527ac 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java @@ -131,8 +131,7 @@ void jummahPrayersProduceSingleJummahFrameWithSlots() { assertThat(frames).hasSize(1); FrameDefinition def = frames.get(0); - // NB: the frameId transliterates as "jumuah" while the frame *type* is "jummah". - assertThat(def.getFrameId()).isEqualTo("jumuah"); + assertThat(def.getFrameId()).isEqualTo("jummah"); assertThat(def.getFrameType()).isEqualTo(FrameType.JUMMAH); JummahFrameConfig config = (JummahFrameConfig) def.getFrameConfig(); From 095c4754372a956a4866aa95fd760423c26eb898 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 22:14:25 -0400 Subject: [PATCH 45/52] Unhook string literals --- .../service/board/producer/NextPrayerFrameProducer.java | 4 +++- .../service/board/producer/PosterFrameProducer.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java index 6d2195b..438e6c9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/NextPrayerFrameProducer.java @@ -20,10 +20,12 @@ @Order(1) public class NextPrayerFrameProducer implements FrameProducer { + public static final String FRAME_ID = "next_prayer"; + @Override public List produce(BoardContext ctx) { return List.of(FrameDefinition.builder() - .frameId("next_prayer") + .frameId(FRAME_ID) .frameType(FrameType.NEXT_PRAYER) .durationInSeconds(configuredDuration(ctx)) .frameConfig(new NextPrayerFrameConfig()) diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java index c8b0929..1d9bcad 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java @@ -18,6 +18,8 @@ @Order(3) public class PosterFrameProducer implements FrameProducer { + private static final String POSTER_FRAME_ID = "poster:"; + private final PosterService posterService; @Override @@ -35,7 +37,7 @@ public FrameDefinition transform(Poster poster, BoardContext ctx) { .build(); return FrameDefinition.builder() - .frameId("poster:" + poster.getId()) + .frameId(POSTER_FRAME_ID + poster.getId()) .frameType(FrameType.POSTER) .durationInSeconds(poster.getDuration()) .frameConfig(config) From a0e1dec5fb3b0619381b9ca398dbe73bdbc7a86e Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 22:20:43 -0400 Subject: [PATCH 46/52] fix vuln --- pom.xml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 01426ac..905667e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.14 + 3.5.16 com.ibrasoft @@ -29,10 +29,8 @@ 21 - 2.8.8 + 2.9.0 1.4 - 8089 9011 From f1b1fc0e4daa3292cbba481b19f7d3d99b14470c Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Mon, 10 Aug 2026 22:34:29 -0400 Subject: [PATCH 47/52] fix more vulns --- pom.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pom.xml b/pom.xml index 905667e..856f179 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,21 @@ 8089 9011 + + + + com.nimbusds + nimbus-jose-jwt + 9.37.4 + + + org.apache.commons + commons-lang3 + 3.20.0 + + + + org.springframework.boot From 90190e0c0140b9ccbe7836488208b74ca1953529 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 11 Aug 2026 13:58:27 -0400 Subject: [PATCH 48/52] Added frame durations for islamic quotes --- openapi.yaml | 21 +++++++++++++++++++ .../board/request/WeeklyContentRequest.java | 12 ++++++++++- .../lensbridge/model/board/IslamicQuote.java | 15 +++++++++++++ .../lensbridge/service/BoardService.java | 1 + .../producer/WeeklyContentFrameProducer.java | 2 +- .../resources/application.properties.example | 9 +++++++- .../V2__add_islamic_quote_duration.sql | 10 +++++++++ .../sqlite/V2__add_islamic_quote_duration.sql | 10 +++++++++ .../lensbridge/service/BoardServiceTest.java | 5 +++-- .../WeeklyContentFrameProducerTest.java | 16 ++++++++++++++ 10 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql create mode 100644 src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql diff --git a/openapi.yaml b/openapi.yaml index 79ccc6f..e2cc5c8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3410,6 +3410,7 @@ components: duration: type: integer format: int32 + minimum: 0 endTime: type: string format: date-time @@ -3418,6 +3419,7 @@ components: format: binary signupUrl: type: string + pattern: "" startTime: type: string format: date-time @@ -3442,6 +3444,7 @@ components: duration: type: integer format: int32 + minimum: 0 footerText: type: string minLength: 1 @@ -3467,6 +3470,7 @@ components: url: type: string minLength: 1 + pattern: "" required: - audience - footerText @@ -3480,6 +3484,7 @@ components: properties: email: type: string + format: email maxLength: 50 minLength: 0 firstName: @@ -3652,6 +3657,9 @@ components: properties: arabic: type: string + durationSeconds: + type: integer + format: int32 id: type: string format: uuid @@ -3690,6 +3698,7 @@ components: deadlineMs: type: integer format: int32 + minimum: 0 kind: type: string minLength: 1 @@ -3699,6 +3708,7 @@ components: ttlSeconds: type: integer format: int32 + minimum: 0 required: - kind IssueEnrollmentTokenRequest: @@ -3716,6 +3726,7 @@ components: expiresInMinutes: type: integer format: int32 + minimum: 0 required: - audience - displayName @@ -3824,6 +3835,7 @@ components: properties: email: type: string + format: email minLength: 1 password: type: string @@ -4171,6 +4183,11 @@ components: properties: arabic: type: string + durationSeconds: + type: integer + format: int32 + maximum: 120 + minimum: 5 kind: type: string enum: @@ -4211,6 +4228,7 @@ components: properties: email: type: string + format: email maxLength: 50 minLength: 0 pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]*\\.utoronto\\.ca$" @@ -4360,6 +4378,7 @@ components: duration: type: integer format: int32 + minimum: 0 endTime: type: string format: date-time @@ -4399,6 +4418,7 @@ components: duration: type: integer format: int32 + minimum: 0 footerText: type: string handle: @@ -4419,6 +4439,7 @@ components: - other url: type: string + pattern: "" UpdateTickerRequest: type: object properties: diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java index b87ea29..344d894 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/WeeklyContentRequest.java @@ -1,11 +1,16 @@ package com.ibrasoft.lensbridge.dto.board.request; import com.ibrasoft.lensbridge.model.board.IslamicQuote; +import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.*; import java.util.List; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class WeeklyContentRequest { + @Valid private List quotes; private List jummahPrayers; @@ -14,7 +19,12 @@ public record QuoteEntry( String arabic, String transliteration, String translation, - String reference) {} + String reference, + @Min(value = DeviceConfig.MIN_SLIDE_SECONDS, + message = "durationSeconds must be null (auto) or between 5 and 120 seconds") + @Max(value = DeviceConfig.MAX_SLIDE_SECONDS, + message = "durationSeconds must be null (auto) or between 5 and 120 seconds") + Integer durationSeconds) {} public record JummahSlot( String prayerTime, diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java b/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java index 3ff3781..d694131 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/IslamicQuote.java @@ -26,4 +26,19 @@ public enum Kind { VERSE, HADITH } private String transliteration; private String translation; private String reference; + + /** + * How long this quote's slide is pinned for, or null for "auto" — the board then sizes the + * dwell time to the text it is actually rendering. + *

+ * Per-quote rather than per-device, like {@link com.ibrasoft.lensbridge.model.board.Poster#getDuration()}: + * a two-line verse and a full-paragraph hadith do not deserve the same dwell time, and that + * difference belongs to the content, not to the screen showing it. Every board renders the + * same weekly content, so a device-level setting could not express it. + *

+ * Nullable because "auto" has to remain reachable — a hadith nobody has timed by hand is + * better measured by the board than pinned to a guessed constant. + */ + @Column(name = "duration_seconds") + private Integer durationSeconds; } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java index 1413c5f..f8adf2c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/BoardService.java @@ -163,6 +163,7 @@ public WeeklyContent saveWeeklyContent(int year, int weekNumber, WeeklyContentRe .transliteration(entry.transliteration()) .translation(entry.translation()) .reference(entry.reference()) + .durationSeconds(entry.durationSeconds()) .build(); content.getQuotes().add(quote); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java index 02e50d2..f05fd2f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducer.java @@ -71,7 +71,7 @@ private FrameDefinition quoteFrame(IslamicQuote quote, IslamicQuoteFrameConfig.K return FrameDefinition.builder() .frameId(QUOTE_FRAME_ID + quote.getId()) .frameType(FrameType.ISLAMIC_QUOTE) - .durationInSeconds(null) + .durationInSeconds(quote.getDurationSeconds()) .frameConfig(config) .build(); } diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example index db567cb..ce443ec 100644 --- a/src/main/resources/application.properties.example +++ b/src/main/resources/application.properties.example @@ -20,7 +20,14 @@ spring.jpa.open-in-view=false # prod is Postgres; see docs/MIGRATIONS.md. spring.jpa.hibernate.ddl-auto=validate spring.flyway.enabled=true -spring.flyway.locations=classpath:db/migration/{vendor} +# Set explicitly per profile below -- classpath:db/migration/postgresql for prod, +# classpath:db/migration/sqlite for dev. Spring Boot's {vendor} placeholder +# (classpath:db/migration/{vendor}) looks like it should pick this automatically from the +# JDBC URL, but inside this project's repackaged jar (Spring Boot 3.2+ nested-jar layout) +# Flyway's classpath scan doesn't restrict to the resolved subdirectory -- it scans both +# db/migration/sqlite and db/migration/postgresql and fails startup with "Found more than +# one migration with version 1". Confirmed against prod on 2026-08-10; see docs/MIGRATIONS.md. +# spring.flyway.locations=classpath:db/migration/postgresql # Allowed CORS origins frontend.baseurl=http://localhost:5173 diff --git a/src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql b/src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql new file mode 100644 index 0000000..a823f81 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2__add_islamic_quote_duration.sql @@ -0,0 +1,10 @@ +-- +-- V2: per-quote slide duration. +-- +-- Nullable with no default on purpose: null is the frame contract's "auto", so every existing +-- quote keeps the behaviour it had before this column existed rather than being backfilled to +-- a duration nobody chose. +-- +-- The SQLite twin of this file must stay at the same version -- see docs/MIGRATIONS.md. + +alter table islamic_quotes add column duration_seconds integer; diff --git a/src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql b/src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql new file mode 100644 index 0000000..e596cfe --- /dev/null +++ b/src/main/resources/db/migration/sqlite/V2__add_islamic_quote_duration.sql @@ -0,0 +1,10 @@ +-- +-- V2: per-quote slide duration. +-- +-- Nullable with no default on purpose: null is the frame contract's "auto", so every existing +-- quote keeps the behaviour it had before this column existed rather than being backfilled to +-- a duration nobody chose. +-- +-- The Postgres twin of this file must stay at the same version -- see docs/MIGRATIONS.md. + +alter table islamic_quotes add column duration_seconds integer; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java index 10c6140..8b71a02 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/BoardServiceTest.java @@ -193,7 +193,7 @@ void saveWeeklyContentCreatesNewWhenAbsentAndMapsQuotes() { WeeklyContentRequest request = WeeklyContentRequest.builder() .quotes(List.of(new WeeklyContentRequest.QuoteEntry( - IslamicQuote.Kind.VERSE, "ar", "tr", "en", "Quran 1:1"))) + IslamicQuote.Kind.VERSE, "ar", "tr", "en", "Quran 1:1", 30))) .jummahPrayers(List.of(new WeeklyContentRequest.JummahSlot("13:30", "Imam", "Hall"))) .build(); @@ -203,6 +203,7 @@ void saveWeeklyContentCreatesNewWhenAbsentAndMapsQuotes() { assertThat(saved.getWeekNumber()).isEqualTo(20); assertThat(saved.getQuotes()).hasSize(1); assertThat(saved.getQuotes().get(0).getKind()).isEqualTo(IslamicQuote.Kind.VERSE); + assertThat(saved.getQuotes().get(0).getDurationSeconds()).isEqualTo(30); assertThat(saved.getJummahPrayers()).hasSize(1); assertThat(saved.getJummahPrayers().get(0).getKhatib()).isEqualTo("Imam"); assertThat(saved.getJummahPrayers().get(0).getPrayerTime().toString()).isEqualTo("13:30"); @@ -219,7 +220,7 @@ void saveWeeklyContentReusesExistingAndClearsQuotesBeforeRepopulating() { WeeklyContentRequest request = WeeklyContentRequest.builder() .quotes(List.of(new WeeklyContentRequest.QuoteEntry( - IslamicQuote.Kind.HADITH, "a", "t", "e", "ref"))) + IslamicQuote.Kind.HADITH, "a", "t", "e", "ref", null))) .build(); WeeklyContent saved = service.saveWeeklyContent(2026, 20, request); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java index b8527ac..f6ebd4b 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/WeeklyContentFrameProducerTest.java @@ -27,6 +27,10 @@ class WeeklyContentFrameProducerTest { private WeeklyContentFrameProducer transformer; private IslamicQuote quote(IslamicQuote.Kind kind, String ref) { + return quote(kind, ref, null); + } + + private IslamicQuote quote(IslamicQuote.Kind kind, String ref, Integer durationSeconds) { return IslamicQuote.builder() .id(UUID.randomUUID()) .kind(kind) @@ -34,6 +38,7 @@ private IslamicQuote quote(IslamicQuote.Kind kind, String ref) { .transliteration("translit-" + ref) .translation("translation-" + ref) .reference(ref) + .durationSeconds(durationSeconds) .build(); } @@ -104,6 +109,17 @@ void nullKindQuoteFallsBackToHadith() { assertThat(config.getKind()).isEqualTo(IslamicQuoteFrameConfig.Kind.HADITH); } + @Test + void quoteDurationIsCarriedOntoTheFrame() { + WeeklyContent content = WeeklyContent.builder() + .quotes(List.of(quote(IslamicQuote.Kind.HADITH, "Muslim 1", 45))) + .build(); + + List frames = transformer.transform(content, null); + + assertThat(frames.get(0).getDurationInSeconds()).isEqualTo(45); + } + @Test void multipleQuotesProduceOneFramePerQuoteInOrder() { WeeklyContent content = WeeklyContent.builder() From 9da8d9e4ae06e545ef465fe508dcb21a8e1f3c61 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 11 Aug 2026 14:09:27 -0400 Subject: [PATCH 49/52] mvnw mode fix --- mvnw | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 mvnw diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 From 95d5ae8b11ee39b77220c38c77c47e34ab88ec3b Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 11 Aug 2026 15:17:20 -0400 Subject: [PATCH 50/52] update emails --- .github/workflows/openapi.yml | 2 +- .../templates/email-verification.html | 249 +++++++------ .../resources/templates/password-reset.html | 272 +++++++------- src/main/resources/templates/welcome.html | 352 +++++++++--------- 4 files changed, 441 insertions(+), 434 deletions(-) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index c1e4e0d..c78f776 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -7,7 +7,7 @@ name: OpenAPI contract on: push: - branches: [main, master] + branches: [master, develop] pull_request: workflow_dispatch: diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html index e42e553..8480653 100644 --- a/src/main/resources/templates/email-verification.html +++ b/src/main/resources/templates/email-verification.html @@ -1,129 +1,134 @@ - - - - - Verify your LensBridge Account - - + + + + + + +Verify your Minbar account + + - - - - - - -
- - -
- - - - - - - - - - - - - - - - - - - - -
- - -
+ + +Confirm your email address to activate your Minbar account. The link expires in 24 hours. + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/resources/templates/password-reset.html b/src/main/resources/templates/password-reset.html index d7947bd..949a27e 100644 --- a/src/main/resources/templates/password-reset.html +++ b/src/main/resources/templates/password-reset.html @@ -1,141 +1,145 @@ - - - - - Reset your LensBridge Password - - + + + + + + +Reset your Minbar password + + - - - - - - -
- - -
- - - - - - - - - - - - - - - - - - - - -
- - -
+ + +Choose a new password for your Minbar account. This reset link expires in 1 hour. + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/resources/templates/welcome.html b/src/main/resources/templates/welcome.html index 12308ab..b476a78 100644 --- a/src/main/resources/templates/welcome.html +++ b/src/main/resources/templates/welcome.html @@ -1,184 +1,182 @@ - - - - - Welcome to LensBridge - - + + + + + + +Welcome to Minbar + + - - - - - - -
- - -
- - - - - - - - - - - - - - - - - - - - -
- - -
+ + +Your account is active. Start sharing your community moments on Minbar today. + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
From ef50c91166be9ccf53fc0828d721a13a0e377fbc Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Tue, 11 Aug 2026 22:36:43 -0400 Subject: [PATCH 51/52] update test cases --- .gitignore | 5 +++++ .../board/producer/AgendaFrameProducer.java | 7 +++---- .../AdminControllerIntegrationTest.java | 9 +++++++++ .../BoardAdminControllerPermissionTest.java | 12 +++++++++++- .../producer/AgendaFrameProducerTest.java | 18 ++++++++++++++++-- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index fae7528..b3a2fab 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ docs/ *.properties +# Test configuration holds no secrets and must be committed: CI checks out a tree with no +# application.properties at all, so without this the test suite runs on the inline @Value +# defaults and behaves differently there than on a developer machine. +!src/test/resources/application.properties + ### IntelliJ IDEA ### .idea *.iws diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java index 16ab24b..50e89d9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducer.java @@ -38,10 +38,9 @@ public List produce(BoardContext ctx) { List events = boardService.getEventsForAudienceInRange( ctx.getDevice().getAudience(), ctx.currentDayStart(), ctx.rollingWindowEnd()); - // An agenda reading "nothing scheduled" for seven straight days is worse than no agenda - // at all. Both frames this replaced skipped themselves when empty; so does this one. -// if (events.isEmpty()) return List.of(); - + // Emitted even when the window is empty. The frames this replaced skipped themselves in + // that case, which left the board with a gap and no explanation; a frame reading "nothing + // scheduled" is the more honest slide. The board decides how to render an empty agenda. return List.of(transform(events, ctx)); } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java index ac5ce89..7ef2751 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AdminControllerIntegrationTest.java @@ -46,9 +46,18 @@ private void expectForbidden(MockHttpServletRequestBuilder request, Role role) t mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(status().isForbidden()); } + /** + * 429 is rejected explicitly: it is not an authorization verdict, so an allow-assertion that + * only looked for 401/403 would pass on a throttled request that never reached the security + * layer, hiding the fact that the test proved nothing. + */ private void expectAllowed(MockHttpServletRequestBuilder request, Role role) throws Exception { mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(result -> { int s = result.getResponse().getStatus(); + if (s == 429) { + throw new AssertionError("Request was rate limited (HTTP 429), so authorization for " + + role + " was never exercised"); + } if (s == 401 || s == 403) { throw new AssertionError("Expected " + role + " to be authorized but got HTTP " + s); } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java index e5e089e..3bdee72 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/BoardAdminControllerPermissionTest.java @@ -68,10 +68,20 @@ private void expectForbidden(MockHttpServletRequestBuilder request, Role role) t mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(status().isForbidden()); } - /** Authorized means "not rejected by security" — the handler's own outcome is out of scope. */ + /** + * Authorized means "not rejected by security" — the handler's own outcome is out of scope. + *

+ * 429 is rejected explicitly. It is not an authorization verdict, so an allow-assertion that + * only looked for 401/403 would pass on a throttled request that never reached the security + * layer, hiding the fact that the test proved nothing. + */ private void expectAllowed(MockHttpServletRequestBuilder request, Role role) throws Exception { mockMvc.perform(request.with(TestAuthorities.as(role))).andExpect(result -> { int s = result.getResponse().getStatus(); + if (s == 429) { + throw new AssertionError("Request was rate limited (HTTP 429), so authorization for " + + role + " was never exercised"); + } if (s == 401 || s == 403) { throw new AssertionError("Expected " + role + " to be authorized but got HTTP " + s); } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java index 0d31e19..e6eaa11 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/AgendaFrameProducerTest.java @@ -200,10 +200,24 @@ void durationIsTheConfiguredValueWhenTheDevicePinsOne() { assertThat(producer.produce(pinned).get(0).getDurationInSeconds()).isEqualTo(35); } + /** + * An empty window still emits the frame. The board needs a slide that says "nothing scheduled" + * rather than silently dropping the agenda out of the rotation, so the shell is always present + * and every day in the window gets its (empty) bucket. + */ @Test - void emitsNoFrameWhenTheWholeWindowIsEmpty() { + void emitsTheFrameWithEmptyBucketsWhenTheWholeWindowIsEmpty() { when(boardService.getEventsForAudienceInRange(any(), any(), any())).thenReturn(List.of()); - assertThat(producer.produce(ctx())).isEmpty(); + List frames = producer.produce(ctx()); + + assertThat(frames).hasSize(1); + assertThat(frames.get(0).getFrameType()).isEqualTo(FrameType.AGENDA); + + AgendaFrameConfig config = (AgendaFrameConfig) frames.get(0).getFrameConfig(); + assertThat(config.getDays()).hasSize(7); + assertThat(config.getDays()).allSatisfy(day -> assertThat(day.getEvents()).isEmpty()); + assertThat(config.getDays().get(0).getDate()).isEqualTo(LocalDate.of(2026, 5, 13)); + assertThat(config.getDays().get(6).getDate()).isEqualTo(LocalDate.of(2026, 5, 19)); } } From 4e57474d95f7d4221b78c92731705dc2154e5059 Mon Sep 17 00:00:00 2001 From: Ibrahim Chehab Date: Wed, 12 Aug 2026 13:15:56 -0400 Subject: [PATCH 52/52] Fix some stuff --- .../service/board/BoardPayloadAssembler.java | 20 ++- .../board/BoardPayloadAssemblerTest.java | 145 ++++++++++++++++++ src/test/resources/application.properties | 2 + 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java create mode 100644 src/test/resources/application.properties diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index aac8361..ffec8bd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -44,7 +44,7 @@ public MusallahBoardPayload assemble(UUID deviceId) { BoardContext ctx = BoardContext.of(device, defaultZone); List frames = frameProducers.stream() - .flatMap(producer -> producer.produce(ctx).stream()) + .flatMap(producer -> produceSafely(producer, ctx).stream()) .collect(Collectors.toList()); return MusallahBoardPayload.builder() @@ -53,4 +53,22 @@ public MusallahBoardPayload assemble(UUID deviceId) { .weather(openWeatherService.getCurrentWeather()) .build(); } + + /** + * Runs one producer, absorbing anything it throws so the rest of the payload still ships. + *

+ * These boards are unattended wall displays. A producer whose query fails should cost the + * board that producer's slides. A single producer failing should not make the entire API + * 500 + */ + private List produceSafely(FrameProducer producer, BoardContext ctx) { + try { + List frames = producer.produce(ctx); + return frames == null ? List.of() : frames; + } catch (RuntimeException e) { + log.error("Frame producer {} failed for device {}; omitting its frames from this payload", + producer.getClass().getSimpleName(), ctx.getDevice().getId(), e); + return List.of(); + } + } } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java new file mode 100644 index 0000000..5e5fe58 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java @@ -0,0 +1,145 @@ +package com.ibrasoft.lensbridge.service.board; + +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import com.ibrasoft.lensbridge.model.board.frames.FrameType; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.OpenWeatherService; +import com.ibrasoft.lensbridge.service.board.producer.FrameProducer; + +import org.junit.jupiter.api.Test; + +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Covers the assembler's fault isolation: a broken producer costs the board its own slides and + * nothing else. See {@code produceSafely}. + */ +class BoardPayloadAssemblerTest { + + private static final ZoneId DEFAULT_ZONE = ZoneId.of("America/Toronto"); + private static final UUID DEVICE_ID = UUID.randomUUID(); + + private final DeviceRepository deviceRepository = mock(DeviceRepository.class); + private final OpenWeatherService weatherService = mock(OpenWeatherService.class); + + private BoardPayloadAssembler assemblerWith(FrameProducer... producers) { + Device device = Device.builder() + .id(DEVICE_ID) + .displayName("test-board") + .audience(Audience.BOTH) + .build(); + when(deviceRepository.findById(DEVICE_ID)).thenReturn(Optional.of(device)); + + return new BoardPayloadAssembler( + deviceRepository, weatherService, List.of(producers), DEFAULT_ZONE); + } + + /** A producer that emits one frame carrying the given id, so order is checkable. */ + private static FrameProducer emitting(String frameId) { + return ctx -> List.of(FrameDefinition.builder() + .frameId(frameId) + .frameType(FrameType.POSTER) + .durationInSeconds(10) + .frameConfig(null) + .build()); + } + + private static FrameProducer throwing(RuntimeException e) { + return ctx -> { + throw e; + }; + } + + private static List frameIdsOf(MusallahBoardPayload payload) { + return payload.getFrames().stream().map(FrameDefinition::getFrameId).toList(); + } + + @Test + void aFailingProducerIsSkippedAndTheRestOfThePayloadSurvives() { + BoardPayloadAssembler assembler = assemblerWith( + emitting("first"), + throwing(new IllegalStateException("poster query blew up")), + emitting("third")); + + MusallahBoardPayload payload = assembler.assemble(DEVICE_ID); + + assertThat(frameIdsOf(payload)).containsExactly("first", "third"); + } + + /** Producers run in injected order; dropping one must not reshuffle the survivors. */ + @Test + void survivingFramesKeepTheirProducerOrder() { + BoardPayloadAssembler assembler = assemblerWith( + throwing(new RuntimeException("boom")), + emitting("a"), + emitting("b"), + throwing(new RuntimeException("boom")), + emitting("c")); + + assertThat(frameIdsOf(assembler.assemble(DEVICE_ID))).containsExactly("a", "b", "c"); + } + + /** A null return is a producer bug, not a reason to blank the board. */ + @Test + void aProducerReturningNullIsTreatedAsEmpty() { + BoardPayloadAssembler assembler = assemblerWith(ctx -> null, emitting("only")); + + assertThat(frameIdsOf(assembler.assemble(DEVICE_ID))).containsExactly("only"); + } + + /** + * Total producer failure still yields a payload. The board gets its config and can render + * its own empty state, which beats a 500 it has no way to display. + */ + @Test + void everyProducerFailingStillReturnsAPayload() { + BoardPayloadAssembler assembler = assemblerWith( + throwing(new RuntimeException("boom")), + throwing(new IllegalArgumentException("also boom"))); + + MusallahBoardPayload payload = assembler.assemble(DEVICE_ID); + + assertThat(payload).isNotNull(); + assertThat(payload.getFrames()).isEmpty(); + } + + /** + * An {@link Error} is not a missing slide. Swallowing it would log an OOM as a skipped + * frame and keep serving payloads from a JVM that should be failing loudly. + */ + @Test + void errorsStillPropagate() { + BoardPayloadAssembler assembler = assemblerWith(ctx -> { + throw new StackOverflowError("recursion in a producer"); + }); + + assertThatThrownBy(() -> assembler.assemble(DEVICE_ID)) + .isInstanceOf(StackOverflowError.class); + } + + /** Degradation is for producers only — an unknown device is still a 404. */ + @Test + void anUnknownDeviceStillFails() { + UUID missing = UUID.randomUUID(); + when(deviceRepository.findById(any())).thenReturn(Optional.empty()); + + BoardPayloadAssembler assembler = new BoardPayloadAssembler( + deviceRepository, weatherService, List.of(emitting("unused")), DEFAULT_ZONE); + + assertThatThrownBy(() -> assembler.assemble(missing)) + .isInstanceOf(ApiResponseException.class); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..6735d56 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,2 @@ +ratelimit.requests=1000000 +ratelimit.duration.minutes=1