diff --git a/.gitignore b/.gitignore index 2f17b22..aabc8fa 100755 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ target/ application.properties application-*.properties +!src/main/resources/application-dev.properties +!src/main/resources/application-prod.properties .settings/ .project .classpath .vscode/ -.env \ No newline at end of file +.env diff --git a/README.md b/README.md index 11cd9ca..9cbeb5e 100755 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ Create that file once on the server with the production values required by the app. The Gitea deploy step verifies that it exists, but it does not create or overwrite it. +Datasource credentials and `JWT_SECRET` are required environment values; they +are intentionally not stored in the Spring profile files. Start from +`misc/org-backend.env.example` and keep the populated file outside the +repository. Rotate any credential that was previously committed before using +this version in production. + The live systemd unit at `/etc/systemd/system/org-backend.service` is managed as a symlink to `misc/org-backend.service`, so the project copy is the source of truth. diff --git a/misc/org-backend.env.example b/misc/org-backend.env.example index 709e9a6..050a995 100755 --- a/misc/org-backend.env.example +++ b/misc/org-backend.env.example @@ -31,7 +31,6 @@ GUACAMOLE_RUN_DIR=/home/zaine GUACAMOLE_CONTAINER_NAME=guacamole GUACAMOLE_START_LOG=/home/zaine/logs/guacamole-start.log GUACAMOLE_STOP_LOG=/home/zaine/logs/guacamole-stop.log -NOSTALGIA_RUN_DIR=/home/zaine/master-folder/projects/_personal/nostalgia NOSTALGIA_RUN_LOG=/home/zaine/logs/nostalgia-prod.log PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves diff --git a/src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java b/src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java new file mode 100644 index 0000000..adb1585 --- /dev/null +++ b/src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java @@ -0,0 +1,31 @@ +package org.zaine.app.calibre.adapter.in.web; + +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.zaine.app.calibre.application.port.in.BrowseCalibreBooksUseCase; +import org.zaine.app.calibre.domain.CalibreBook; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/calibre") +@Tag(name = "Calibre", description = "Calibre catalogue") +public class CalibreController { + private final BrowseCalibreBooksUseCase books; + + public CalibreController(BrowseCalibreBooksUseCase books) { + this.books = books; + } + + @GetMapping("/books") + public List getBooks() { + return books.getBooks().stream().map(BookResponse::from).toList(); + } + + public record BookResponse(long id, String title, String authors) { + static BookResponse from(CalibreBook book) { + return new BookResponse(book.id(), book.title(), book.authors()); + } + } +} diff --git a/src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java b/src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java new file mode 100644 index 0000000..7c8f4d6 --- /dev/null +++ b/src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java @@ -0,0 +1,46 @@ +package org.zaine.app.calibre.adapter.out.sqlite; + +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.zaine.app.calibre.application.port.out.CalibreCatalogPort; +import org.zaine.app.calibre.domain.CalibreBook; + +@Component +class SqliteCalibreCatalog implements CalibreCatalogPort { + private static final Logger log = LoggerFactory.getLogger(SqliteCalibreCatalog.class); + private static final String QUERY = """ + SELECT b.id AS book_id, b.title AS title, GROUP_CONCAT(a.name, ', ') AS authors + FROM books b + LEFT JOIN books_authors_link bal ON b.id = bal.book + LEFT JOIN authors a ON bal.author = a.id + GROUP BY b.id + ORDER BY b.title COLLATE NOCASE + """; + + private final String databasePath; + + SqliteCalibreCatalog(@Value("${calibre.db.path}") String databasePath) { + this.databasePath = databasePath; + } + + @Override + public List findAll() { + List books = new ArrayList<>(); + try (var connection = DriverManager.getConnection("jdbc:sqlite:" + databasePath); + var statement = connection.createStatement(); + var results = statement.executeQuery(QUERY)) { + while (results.next()) { + books.add(new CalibreBook(results.getLong("book_id"), results.getString("title"), + results.getString("authors"))); + } + } catch (Exception ex) { + log.warn("Calibre catalogue is unavailable at {}: {}", databasePath, ex.getMessage()); + } + return books; + } +} diff --git a/src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java b/src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java new file mode 100644 index 0000000..7476aa1 --- /dev/null +++ b/src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java @@ -0,0 +1,8 @@ +package org.zaine.app.calibre.application.port.in; + +import java.util.List; +import org.zaine.app.calibre.domain.CalibreBook; + +public interface BrowseCalibreBooksUseCase { + List getBooks(); +} diff --git a/src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java b/src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java new file mode 100644 index 0000000..527d3f9 --- /dev/null +++ b/src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java @@ -0,0 +1,8 @@ +package org.zaine.app.calibre.application.port.out; + +import java.util.List; +import org.zaine.app.calibre.domain.CalibreBook; + +public interface CalibreCatalogPort { + List findAll(); +} diff --git a/src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java b/src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java new file mode 100644 index 0000000..b60ca83 --- /dev/null +++ b/src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java @@ -0,0 +1,21 @@ +package org.zaine.app.calibre.application.service; + +import java.util.List; +import org.springframework.stereotype.Service; +import org.zaine.app.calibre.application.port.in.BrowseCalibreBooksUseCase; +import org.zaine.app.calibre.application.port.out.CalibreCatalogPort; +import org.zaine.app.calibre.domain.CalibreBook; + +@Service +public class CalibreApplicationService implements BrowseCalibreBooksUseCase { + private final CalibreCatalogPort catalog; + + public CalibreApplicationService(CalibreCatalogPort catalog) { + this.catalog = catalog; + } + + @Override + public List getBooks() { + return catalog.findAll(); + } +} diff --git a/src/main/java/org/zaine/app/calibre/domain/CalibreBook.java b/src/main/java/org/zaine/app/calibre/domain/CalibreBook.java new file mode 100644 index 0000000..7129ea2 --- /dev/null +++ b/src/main/java/org/zaine/app/calibre/domain/CalibreBook.java @@ -0,0 +1,7 @@ +package org.zaine.app.calibre.domain; + +public record CalibreBook(long id, String title, String authors) { + public CalibreBook { + authors = authors == null ? "" : authors; + } +} diff --git a/src/main/java/org/zaine/app/common/application/ApplicationException.java b/src/main/java/org/zaine/app/common/application/ApplicationException.java new file mode 100644 index 0000000..1b19e49 --- /dev/null +++ b/src/main/java/org/zaine/app/common/application/ApplicationException.java @@ -0,0 +1,36 @@ +package org.zaine.app.common.application; + +public class ApplicationException extends RuntimeException { + public enum Kind { BAD_REQUEST, NOT_FOUND, UNAUTHORIZED, PAYLOAD_TOO_LARGE, FAILURE } + + private final Kind kind; + + private ApplicationException(Kind kind, String message) { + super(message == null || message.isBlank() ? "Operation failed" : message); + this.kind = kind; + } + + public Kind kind() { + return kind; + } + + public static ApplicationException badRequest(String message) { + return new ApplicationException(Kind.BAD_REQUEST, message); + } + + public static ApplicationException notFound(String message) { + return new ApplicationException(Kind.NOT_FOUND, message); + } + + public static ApplicationException unauthorized(String message) { + return new ApplicationException(Kind.UNAUTHORIZED, message); + } + + public static ApplicationException payloadTooLarge(String message) { + return new ApplicationException(Kind.PAYLOAD_TOO_LARGE, message); + } + + public static ApplicationException failure(String message) { + return new ApplicationException(Kind.FAILURE, message); + } +} diff --git a/src/main/java/org/zaine/app/config/BuildProperties.java b/src/main/java/org/zaine/app/config/BuildProperties.java new file mode 100644 index 0000000..50c8878 --- /dev/null +++ b/src/main/java/org/zaine/app/config/BuildProperties.java @@ -0,0 +1,64 @@ +package org.zaine.app.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class BuildProperties { + private final String webDirectory; + private final String webLog; + private final String emacsDirectory; + private final String emacsLog; + private final String combinedLog; + private final String resourceDirectory; + private final String resourceLog; + private final String resourceCommand; + private final String guacamoleDirectory; + private final String guacamoleContainer; + private final String guacamoleStartLog; + private final String guacamoleStopLog; + private final String nostalgiaLog; + + public BuildProperties( + @Value("${zone.build.dir}") String webDirectory, + @Value("${zone.build.log}") String webLog, + @Value("${emacs.run.dir}") String emacsDirectory, + @Value("${emacs.run.log}") String emacsLog, + @Value("${combined.run.log}") String combinedLog, + @Value("${adventure.resources.free.dir:/home/zaine}") String resourceDirectory, + @Value("${adventure.resources.free.log:/home/zaine/logs/adventure-resources.log}") String resourceLog, + @Value("${adventure.resources.free.command:sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af}") String resourceCommand, + @Value("${guacamole.run.dir:/home/zaine}") String guacamoleDirectory, + @Value("${guacamole.container.name:guacamole}") String guacamoleContainer, + @Value("${guacamole.start.log:/home/zaine/logs/guacamole-start.log}") String guacamoleStartLog, + @Value("${guacamole.stop.log:/home/zaine/logs/guacamole-stop.log}") String guacamoleStopLog, + @Value("${nostalgia.run.log:/home/zaine/logs/nostalgia-prod.log}") String nostalgiaLog) { + this.webDirectory = webDirectory; + this.webLog = webLog; + this.emacsDirectory = emacsDirectory; + this.emacsLog = emacsLog; + this.combinedLog = combinedLog; + this.resourceDirectory = resourceDirectory; + this.resourceLog = resourceLog; + this.resourceCommand = resourceCommand; + this.guacamoleDirectory = guacamoleDirectory; + this.guacamoleContainer = guacamoleContainer; + this.guacamoleStartLog = guacamoleStartLog; + this.guacamoleStopLog = guacamoleStopLog; + this.nostalgiaLog = nostalgiaLog; + } + + public String webDirectory() { return webDirectory; } + public String webLog() { return webLog; } + public String emacsDirectory() { return emacsDirectory; } + public String emacsLog() { return emacsLog; } + public String combinedLog() { return combinedLog; } + public String resourceDirectory() { return resourceDirectory; } + public String resourceLog() { return resourceLog; } + public String resourceCommand() { return resourceCommand; } + public String guacamoleDirectory() { return guacamoleDirectory; } + public String guacamoleContainer() { return guacamoleContainer; } + public String guacamoleStartLog() { return guacamoleStartLog; } + public String guacamoleStopLog() { return guacamoleStopLog; } + public String nostalgiaLog() { return nostalgiaLog; } +} diff --git a/src/main/java/org/zaine/app/controller/CalibreController.java b/src/main/java/org/zaine/app/controller/CalibreController.java deleted file mode 100755 index 724a894..0000000 --- a/src/main/java/org/zaine/app/controller/CalibreController.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.zaine.app.controller; - -import java.util.List; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.zaine.app.dto.CalibreBookDTO; -import org.zaine.app.service.CalibreService; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; - -@RestController -@RequestMapping("/api/calibre") -@Tag(name = "Calibre", description = "Calibre API") -public class CalibreController { - - @Autowired - private CalibreService calibreService; - - @Operation(summary = "Get all books", description = "Fetches all books from the Calibre database") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Books retrieved successfully"), - @ApiResponse(responseCode = "404", description = "No books found"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) - @GetMapping("/books") - public List getBooks() { - return calibreService.getAllBooks(); - } -} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/controller/CheckinController.java b/src/main/java/org/zaine/app/controller/CheckinController.java deleted file mode 100755 index 5db4c86..0000000 --- a/src/main/java/org/zaine/app/controller/CheckinController.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.zaine.app.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -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.zaine.app.dto.EveningCheckinRequest; -import org.zaine.app.dto.MorningCheckinRequest; -import org.zaine.app.dto.TodayStatusDTO; -import org.zaine.app.service.CheckinService; - -import java.time.LocalDate; -import java.util.List; - -@RestController -@RequestMapping("/api/checkin") -@Tag(name = "Check-in", description = "Daily morning and evening check-ins") -public class CheckinController { - - private final CheckinService checkinService; - - public CheckinController(CheckinService checkinService) { - this.checkinService = checkinService; - } - - @Operation(summary = "Get today's check-in status") - @GetMapping("/today-status") - public TodayStatusDTO getTodayStatus() { - return checkinService.getTodayStatus(); - } - - @Operation(summary = "Get check-in history") - @GetMapping("/history") - public List getHistory( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return checkinService.getHistory(from, to); - } - - @Operation(summary = "Submit morning check-in") - @PostMapping("/morning") - public ResponseEntity submitMorning(@RequestBody MorningCheckinRequest request) { - return ResponseEntity.ok(checkinService.saveMorning(request)); - } - - @Operation(summary = "Submit evening check-in") - @PostMapping("/evening") - public ResponseEntity submitEvening(@RequestBody EveningCheckinRequest request) { - return ResponseEntity.ok(checkinService.saveEvening(request)); - } -} diff --git a/src/main/java/org/zaine/app/controller/CommentsController.java b/src/main/java/org/zaine/app/controller/CommentsController.java index 22e3cc4..596748d 100755 --- a/src/main/java/org/zaine/app/controller/CommentsController.java +++ b/src/main/java/org/zaine/app/controller/CommentsController.java @@ -1,9 +1,8 @@ package org.zaine.app.controller; -import java.lang.System.Logger; import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; +import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -24,10 +23,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Comments", description = "Endpoints for managing comments") public class CommentsController { - private static final Logger logger = System.getLogger(CommentsController.class.getName()); - - @Autowired - private CommentsService commentsService; + private final CommentsService commentsService; + + public CommentsController(CommentsService commentsService) { + this.commentsService = commentsService; + } @Operation( summary = "Get comments by page slug", @@ -39,9 +39,8 @@ public class CommentsController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/{page_slug}") - public List getAllCommentsBySlug(@PathVariable String page_slug) { - List allComments = commentsService.getAllCommentsBySlug(page_slug); - return allComments; + public List getAllCommentsBySlug(@PathVariable String page_slug) { + return commentsService.getAllCommentsBySlug(page_slug).stream().map(CommentResponse::from).toList(); } @Operation( @@ -54,8 +53,8 @@ public class CommentsController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/item/{id}") - public Comments getCommentById(@PathVariable Integer id) { - return commentsService.getCommentById(id); + public CommentResponse getCommentById(@PathVariable Integer id) { + return CommentResponse.from(commentsService.getCommentById(id)); } @Operation( @@ -68,8 +67,8 @@ public class CommentsController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("") - public List getAllComments() { - return commentsService.getAllComments(); + public List getAllComments() { + return commentsService.getAllComments().stream().map(CommentResponse::from).toList(); } @Operation( @@ -87,22 +86,7 @@ public class CommentsController { ) public void addComment(@RequestBody CreateCommentDTO dto) { - if (dto.getContent() == null || dto.getContent().trim().isEmpty()) { - logger.log(System.Logger.Level.WARNING, - "Attempted to add empty comment. Operation aborted."); - return; - } - - Comments newComment = new Comments(); - newComment.setPageSlug(dto.getPageSlug()); - newComment.setAuthor(dto.getAuthor()); - newComment.setContent(dto.getContent()); - newComment.setParentId(dto.getParentId()); - newComment.setCreatedAt(java.time.Instant.now()); - - logger.log(System.Logger.Level.INFO, () -> "Adding comment to pageSlug: " + dto.getPageSlug()); - - commentsService.addComment(newComment); + commentsService.addComment(dto.getPageSlug(), dto.getAuthor(), dto.getContent(), dto.getParentId()); } @Operation( @@ -115,7 +99,21 @@ public class CommentsController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/thread/{comment_id}") - public List getCommentThread(@PathVariable Integer comment_id) { - return commentsService.getCommentThread(comment_id); + public List getCommentThread(@PathVariable Integer comment_id) { + return commentsService.getCommentThread(comment_id).stream().map(CommentResponse::from).toList(); + } + + public record CommentResponse( + Integer id, + String pageSlug, + String author, + String content, + @JsonProperty("created_at") java.time.Instant createdAt, + @JsonProperty("parent_id") Integer parentId) { + static CommentResponse from(Comments comment) { + if (comment == null) return null; + return new CommentResponse(comment.getId(), comment.getPageSlug(), comment.getAuthor(), + comment.getContent(), comment.getCreatedAt(), comment.getParentId()); + } } } diff --git a/src/main/java/org/zaine/app/controller/CompetenciesController.java b/src/main/java/org/zaine/app/controller/CompetenciesController.java index 37bbb4b..9e7bd0d 100755 --- a/src/main/java/org/zaine/app/controller/CompetenciesController.java +++ b/src/main/java/org/zaine/app/controller/CompetenciesController.java @@ -1,7 +1,6 @@ package org.zaine.app.controller; import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -23,8 +22,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Competencies", description = "Operations about competencies") public class CompetenciesController { - @Autowired - private CompetenciesService competenciesService; + private final CompetenciesService competenciesService; + + public CompetenciesController(CompetenciesService competenciesService) { + this.competenciesService = competenciesService; + } @Operation( summary = "Get all competencies", @@ -36,12 +38,12 @@ public class CompetenciesController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/items") - public List getAllCompetencies( + public List getAllCompetencies( @RequestParam(value = "group", required = false) String group) { if (group != null && !group.isBlank()) { - return competenciesService.getCompetenciesByGroup(group); + return competenciesService.getCompetenciesByGroup(group).stream().map(CompetencyResponse::from).toList(); } - return competenciesService.getAllCompetencies(); + return competenciesService.getAllCompetencies().stream().map(CompetencyResponse::from).toList(); } @Operation( @@ -54,8 +56,8 @@ public class CompetenciesController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/item/{id}") - public Competencies getCompetencyById(@PathVariable Integer id) { - return competenciesService.getCompetencyById(id); + public CompetencyResponse getCompetencyById(@PathVariable Integer id) { + return CompetencyResponse.from(competenciesService.getCompetencyById(id)); } @Operation( @@ -77,4 +79,12 @@ public class CompetenciesController { ) { competenciesService.updateCompetencyState(id, request.getState()); } + + public record CompetencyResponse(Integer id, String title, String state, java.time.Instant createdAt, String group) { + static CompetencyResponse from(Competencies competency) { + if (competency == null) return null; + return new CompetencyResponse(competency.getId(), competency.getTitle(), competency.getState(), + competency.getCreatedAt(), competency.getGroup()); + } + } } diff --git a/src/main/java/org/zaine/app/controller/NotesController.java b/src/main/java/org/zaine/app/controller/NotesController.java deleted file mode 100755 index a5ebf90..0000000 --- a/src/main/java/org/zaine/app/controller/NotesController.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.zaine.app.controller; - -import java.util.List; - -import org.springframework.beans.factory.annotation.Autowired; -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.RestController; -import org.zaine.app.dto.CreateNoteDTO; -import org.zaine.app.model.Notes; -import org.zaine.app.service.NotesService; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; - -@RestController -@RequestMapping("/api") -@Tag(name = "Notes", description = "Operations related to notes") -public class NotesController { - @Autowired - private NotesService notesService; - - - @Operation( - summary = "Get all notes", - description = "Returns a list of all notes" - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Notes retrieved successfully"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) - @GetMapping("/notes") - public List getAllNotes() { - return notesService.getAllNotes(); - } - - @Operation( - summary = "Create a note object", - description = "Creates a new note" - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Note created successfully"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) - @PostMapping( - value = "/notes", - consumes = "application/json" - ) - public Notes createNote(@RequestBody CreateNoteDTO dto) { - - Notes note = new Notes(); - note.setAuthorName(dto.getAuthorName()); - note.setContent(dto.getContent()); - - return notesService.createNote(note); - } -} - diff --git a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java deleted file mode 100755 index 9210cce..0000000 --- a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java +++ /dev/null @@ -1,172 +0,0 @@ -package org.zaine.app.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -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.zaine.app.dto.PeriodCompanionDTO.CalendarDayDTO; -import org.zaine.app.dto.PeriodCompanionDTO.CreateSymptomRequest; -import org.zaine.app.dto.PeriodCompanionDTO.DailyLogDTO; -import org.zaine.app.dto.PeriodCompanionDTO.DailyLogRequest; -import org.zaine.app.dto.PeriodCompanionDTO.DashboardDTO; -import org.zaine.app.dto.PeriodCompanionDTO.InsightsDTO; -import org.zaine.app.dto.PeriodCompanionDTO.FastingLogDTO; -import org.zaine.app.dto.PeriodCompanionDTO.FastingLogRequest; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleDTO; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleRequest; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodEntryRequest; -import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO; -import org.zaine.app.dto.PeriodCompanionDTO.ReflectionDTO; -import org.zaine.app.dto.PeriodCompanionDTO.ReflectionRequest; -import org.zaine.app.dto.PeriodCompanionDTO.SettingsDTO; -import org.zaine.app.dto.PeriodCompanionDTO.SettingsRequest; -import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO; -import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest; -import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO; -import org.zaine.app.dto.PeriodCompanionDTO.WellbeingDTO; -import org.zaine.app.service.PeriodCompanionService; - -import java.time.LocalDate; -import java.util.List; - -@RestController -@RequestMapping("/api/period-companion") -@Tag(name = "Period Companion", description = "Private menstrual cycle tracking for shared visibility") -public class PeriodCompanionController { - private final PeriodCompanionService periodCompanionService; - - public PeriodCompanionController(PeriodCompanionService periodCompanionService) { - this.periodCompanionService = periodCompanionService; - } - - @Operation(summary = "Get period companion dashboard") - @GetMapping("/dashboard") - public DashboardDTO getDashboard() { - return periodCompanionService.getDashboard(); - } - - @Operation(summary = "Get calendar days for a date range") - @GetMapping("/calendar") - public List getCalendar( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return periodCompanionService.getCalendar(from, to); - } - - @Operation(summary = "Get daily logs for a date range") - @GetMapping("/daily-logs") - public List getDailyLogs( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return periodCompanionService.getDailyLogs(from, to); - } - - @Operation(summary = "Get a daily log by date") - @GetMapping("/daily-logs/{date}") - public DailyLogDTO getDailyLog(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { - return periodCompanionService.getDailyLog(date); - } - - @Operation(summary = "Create or update a daily log") - @PostMapping("/daily-logs") - public ResponseEntity saveDailyLog(@RequestBody DailyLogRequest request) { - return ResponseEntity.ok(periodCompanionService.saveDailyLog(request)); - } - - @Operation(summary = "Get symptoms") - @GetMapping("/symptoms") - public List getSymptoms() { - return periodCompanionService.getSymptoms(); - } - - @Operation(summary = "Create a symptom") - @PostMapping("/symptoms") - public ResponseEntity createSymptom(@RequestBody CreateSymptomRequest request) { - return ResponseEntity.ok(periodCompanionService.createSymptom(request)); - } - - @Operation(summary = "Get tracked cycles") - @GetMapping("/cycles") - public List getCycles() { - return periodCompanionService.getCycles(); - } - - @Operation(summary = "Create or update a cycle") - @PostMapping("/cycles") - public ResponseEntity saveCycle(@RequestBody PeriodCycleRequest request) { - return ResponseEntity.ok(periodCompanionService.saveCycle(request)); - } - - @Operation(summary = "Create or update a period entry and matching daily flow logs") - @PostMapping("/period-entries") - public ResponseEntity savePeriodEntry(@RequestBody PeriodEntryRequest request) { - return ResponseEntity.ok(periodCompanionService.savePeriodEntry(request)); - } - - @Operation(summary = "Generate and store a prediction snapshot") - @PostMapping("/predictions/generate") - public ResponseEntity generatePrediction() { - return ResponseEntity.ok(periodCompanionService.generatePrediction()); - } - - @Operation(summary = "Get period companion insights") - @GetMapping("/insights") - public InsightsDTO getInsights() { - return periodCompanionService.getInsights(); - } - - @Operation(summary = "Get cycle-aware wellbeing guidance") - @GetMapping("/wellbeing") - public WellbeingDTO getWellbeing() { - return periodCompanionService.getWellbeing(); - } - - @Operation(summary = "Get fasting make-up logs") - @GetMapping("/fasting-logs") - public List getFastingLogs() { - return periodCompanionService.getFastingLogs(); - } - - @Operation(summary = "Create or update a fasting make-up log") - @PostMapping("/fasting-logs") - public ResponseEntity saveFastingLog(@RequestBody FastingLogRequest request) { - return ResponseEntity.ok(periodCompanionService.saveFastingLog(request)); - } - - @Operation(summary = "Create or update a daily reflection") - @PostMapping("/reflections") - public ResponseEntity saveReflection(@RequestBody ReflectionRequest request) { - return ResponseEntity.ok(periodCompanionService.saveReflection(request)); - } - - @Operation(summary = "Get period companion settings") - @GetMapping("/settings") - public SettingsDTO getSettings() { - return periodCompanionService.getSettings(); - } - - @Operation(summary = "Update period companion settings") - @PostMapping("/settings") - public ResponseEntity updateSettings(@RequestBody SettingsRequest request) { - return ResponseEntity.ok(periodCompanionService.updateSettings(request)); - } - - @Operation(summary = "Get recent support messages") - @GetMapping("/support-messages") - public List getSupportMessages() { - return periodCompanionService.getSupportMessages(); - } - - @Operation(summary = "Create a support message") - @PostMapping("/support-messages") - public ResponseEntity createSupportMessage(@RequestBody SupportMessageRequest request) { - return ResponseEntity.ok(periodCompanionService.createSupportMessage(request)); - } -} diff --git a/src/main/java/org/zaine/app/controller/WirdController.java b/src/main/java/org/zaine/app/controller/WirdController.java index 9d90e31..8e8ecfc 100755 --- a/src/main/java/org/zaine/app/controller/WirdController.java +++ b/src/main/java/org/zaine/app/controller/WirdController.java @@ -1,10 +1,10 @@ package org.zaine.app.controller; -import java.lang.System.Logger; import java.time.LocalDate; import java.util.List; - -import org.springframework.beans.factory.annotation.Autowired; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -29,22 +29,24 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Wird", description = "Wird API") public class WirdController { - private static final Logger logger = System.getLogger(WirdController.class.getName()); + private static final Logger log = LoggerFactory.getLogger(WirdController.class); - @Autowired - private WirdService wirdService; + private final WirdService wirdService; + private final MotalahService motalahService; - @Autowired - private MotalahService motalahService; + public WirdController(WirdService wirdService, MotalahService motalahService) { + this.wirdService = wirdService; + this.motalahService = motalahService; + } /** * GET /api/wird/entries * Returns all entries, newest first. Used by history table and today cards. */ @GetMapping("/entries") - public List getAllEntries() { - logger.log(Logger.Level.INFO, "Fetching all wird entries"); - return wirdService.getAllEntries(); + public List getAllEntries() { + log.debug("Fetching all wird entries"); + return toWirdResponses(wirdService.getAllEntries()); } /** @@ -52,8 +54,8 @@ public class WirdController { * Convenience endpoint for today's entries only. */ @GetMapping("/entries/today") - public List getTodayEntries() { - return wirdService.getTodayEntries(); + public List getTodayEntries() { + return toWirdResponses(wirdService.getTodayEntries()); } /** @@ -61,10 +63,10 @@ public class WirdController { * Used by trend chart to fetch a date window. */ @GetMapping("/entries/range") - public List getEntriesInRange( + public List getEntriesInRange( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return wirdService.getEntriesInRange(from, to); + return toWirdResponses(wirdService.getEntriesInRange(from, to)); } /** @@ -72,11 +74,11 @@ public class WirdController { * Filtered by wird type — useful if you want to extend the chart later. */ @GetMapping("/entries/type/{type}") - public List getEntriesByType( + public List getEntriesByType( @PathVariable String type, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return wirdService.getEntriesByTypeInRange(type, from, to); + return toWirdResponses(wirdService.getEntriesByTypeInRange(type, from, to)); } /** @@ -85,10 +87,10 @@ public class WirdController { * Creates a new log entry. */ @PostMapping("/entries") - public ResponseEntity createEntry(@RequestBody WirdEntryDTO dto) { - logger.log(Logger.Level.INFO, "Creating wird entry: {0} on {1}", dto.getWirdType(), dto.getDate()); + public ResponseEntity createEntry(@RequestBody WirdEntryDTO dto) { + log.info("Creating wird entry type={} date={}", dto.getWirdType(), dto.getDate()); WirdEntry saved = wirdService.createEntry(dto); - return ResponseEntity.ok(saved); + return ResponseEntity.ok(WirdEntryResponse.from(saved)); } /** @@ -97,7 +99,7 @@ public class WirdController { */ @DeleteMapping("/entries/{id}") public ResponseEntity deleteEntry(@PathVariable Long id) { - logger.log(Logger.Level.INFO, "Deleting wird entry with id: {0}", id); + log.info("Deleting wird entry id={}", id); wirdService.deleteEntry(id); return ResponseEntity.noContent().build(); } @@ -107,8 +109,8 @@ public class WirdController { * Returns today's nafl prayer entries (convenience endpoint). */ @GetMapping("/entries/nafl/today") - public List getNaflToday() { - return wirdService.getNaflForDate(LocalDate.now()); + public List getNaflToday() { + return toWirdResponses(wirdService.getNaflForDate(LocalDate.now())); } /** @@ -116,8 +118,8 @@ public class WirdController { * All khatm completions, newest first. */ @GetMapping("/entries/khatm") - public List getKhatmEntries() { - return wirdService.getKhatmEntries(); + public List getKhatmEntries() { + return toWirdResponses(wirdService.getKhatmEntries()); } /** @@ -125,9 +127,9 @@ public class WirdController { * All study sessions, newest first. */ @GetMapping("/motalah") - public List getAll() { - logger.log(Logger.Level.INFO, "Fetching all motalah sessions"); - return motalahService.getAll(); + public List getAll() { + log.debug("Fetching all motalah sessions"); + return toMotalahResponses(motalahService.getAll()); } /** @@ -135,8 +137,8 @@ public class WirdController { * Convenience endpoint for today's sessions only. */ @GetMapping("/motalah/today") - public List getToday() { - return motalahService.getToday(); + public List getToday() { + return toMotalahResponses(motalahService.getToday()); } /** @@ -144,10 +146,10 @@ public class WirdController { * Used by the trend chart and month-total stat. */ @GetMapping("/motalah/range") - public List getInRange( + public List getInRange( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return motalahService.getInRange(from, to); + return toMotalahResponses(motalahService.getInRange(from, to)); } /** @@ -155,11 +157,10 @@ public class WirdController { * Body: { date, durationMinutes, bookIds, notes } */ @PostMapping("/motalah") - public ResponseEntity create(@RequestBody MotalahSessionDTO dto) { - logger.log(Logger.Level.INFO, "Creating motalah session: {0} min on {1}", - dto.getDurationMinutes(), dto.getDate()); + public ResponseEntity create(@RequestBody MotalahSessionDTO dto) { + log.info("Creating motalah session durationMinutes={} date={}", dto.getDurationMinutes(), dto.getDate()); MotalahSession saved = motalahService.create(dto); - return ResponseEntity.ok(saved); + return ResponseEntity.ok(MotalahResponse.from(saved)); } /** @@ -167,9 +168,33 @@ public class WirdController { */ @DeleteMapping("/motalah/{id}") public ResponseEntity delete(@PathVariable Long id) { - logger.log(Logger.Level.INFO, "Deleting motalah session id: {0}", id); + log.info("Deleting motalah session id={}", id); motalahService.delete(id); return ResponseEntity.noContent().build(); } + + private static List toWirdResponses(List entries) { + return entries.stream().map(WirdEntryResponse::from).toList(); + } + + private static List toMotalahResponses(List sessions) { + return sessions.stream().map(MotalahResponse::from).toList(); + } + + public record WirdEntryResponse(Long id, String wirdType, LocalDate date, java.math.BigDecimal value, + String notes, java.time.OffsetDateTime createdAt) { + static WirdEntryResponse from(WirdEntry entry) { + return new WirdEntryResponse(entry.getId(), entry.getWirdType(), entry.getDate(), entry.getValue(), + entry.getNotes(), entry.getCreatedAt()); + } + } + + public record MotalahResponse(Long id, LocalDate date, Integer durationMinutes, List bookIds, + String notes, java.time.OffsetDateTime createdAt) { + static MotalahResponse from(MotalahSession session) { + return new MotalahResponse(session.getId(), session.getDate(), session.getDurationMinutes(), + session.getBookIds(), session.getNotes(), session.getCreatedAt()); + } + } } diff --git a/src/main/java/org/zaine/app/controller/zone/BuildController.java b/src/main/java/org/zaine/app/controller/zone/BuildController.java index d4b460a..919fc21 100755 --- a/src/main/java/org/zaine/app/controller/zone/BuildController.java +++ b/src/main/java/org/zaine/app/controller/zone/BuildController.java @@ -19,8 +19,7 @@ import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; +import org.zaine.app.config.BuildProperties; import org.zaine.app.service.BuildRunStateService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -67,31 +66,41 @@ public class BuildController { private final AtomicReference resourceProcess = new AtomicReference<>(); private final AtomicReference guacamoleStartProcess = new AtomicReference<>(); private final AtomicReference guacamoleStopProcess = new AtomicReference<>(); - private final AtomicReference nostalgiaProcess = new AtomicReference<>(); - @Autowired - public BuildController(BuildRunStateService buildRunState) { + public BuildController(BuildRunStateService buildRunState, BuildProperties properties) { this.buildRunState = buildRunState; + this.webBuildDirectory = properties.webDirectory(); + this.webBuildLogFile = properties.webLog(); + this.emacsRunDirectory = properties.emacsDirectory(); + this.emacsRunLogFile = properties.emacsLog(); + this.combinedRunLogFile = properties.combinedLog(); + this.resourceFreeDirectory = properties.resourceDirectory(); + this.resourceFreeLogFile = properties.resourceLog(); + this.resourceFreeCommand = properties.resourceCommand(); + this.guacamoleRunDirectory = properties.guacamoleDirectory(); + this.guacamoleContainerName = properties.guacamoleContainer(); + this.guacamoleStartLogFile = properties.guacamoleStartLog(); + this.guacamoleStopLogFile = properties.guacamoleStopLog(); + this.nostalgiaRunLogFile = properties.nostalgiaLog(); } /* ========================================================= CONFIG ========================================================= */ - @Value("${zone.build.dir}") private String webBuildDirectory; - @Value("${zone.build.log}") private String webBuildLogFile; - @Value("${emacs.run.dir}") private String emacsRunDirectory; - @Value("${emacs.run.log}") private String emacsRunLogFile; - @Value("${combined.run.log}") private String combinedRunLogFile; - @Value("${adventure.resources.free.dir:/home/zaine}") private String resourceFreeDirectory; - @Value("${adventure.resources.free.log:/home/zaine/logs/adventure-resources.log}") private String resourceFreeLogFile; - @Value("${adventure.resources.free.command:sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af}") private String resourceFreeCommand; - @Value("${guacamole.run.dir:/home/zaine}") private String guacamoleRunDirectory; - @Value("${guacamole.container.name:guacamole}") private String guacamoleContainerName; - @Value("${guacamole.start.log:/home/zaine/logs/guacamole-start.log}") private String guacamoleStartLogFile; - @Value("${guacamole.stop.log:/home/zaine/logs/guacamole-stop.log}") private String guacamoleStopLogFile; - @Value("${nostalgia.run.dir:/home/zaine/master-folder/projects/_personal/nostalgia}") private String nostalgiaRunDirectory; - @Value("${nostalgia.run.log:/home/zaine/logs/nostalgia-prod.log}") private String nostalgiaRunLogFile; + private final String webBuildDirectory; + private final String webBuildLogFile; + private final String emacsRunDirectory; + private final String emacsRunLogFile; + private final String combinedRunLogFile; + private final String resourceFreeDirectory; + private final String resourceFreeLogFile; + private final String resourceFreeCommand; + private final String guacamoleRunDirectory; + private final String guacamoleContainerName; + private final String guacamoleStartLogFile; + private final String guacamoleStopLogFile; + private final String nostalgiaRunLogFile; /* ========================================================= HEALTH + UPTIME @@ -356,31 +365,6 @@ public class BuildController { return streamLogs(guacamoleStopLogFile); } - @Operation( - summary = "Run Nostalgia production container", - description = "Runs docker compose up -d --build in the Nostalgia project directory" - ) - @PostMapping("/nostalgia/production") - public ResponseEntity runNostalgiaProduction() { - return startCommand( - BuildRunStateService.NOSTALGIA_PROD, - "nostalgia production", - nostalgiaRunDirectory, - nostalgiaRunLogFile, - List.of("docker", "compose", "up", "-d", "--build"), - nostalgiaProcess - ); - } - - @DeleteMapping("/nostalgia/production") - public ResponseEntity cancelNostalgiaProduction() { - return killProcess( - nostalgiaProcess, - BuildRunStateService.NOSTALGIA_PROD, - "nostalgia production" - ); - } - @GetMapping("/nostalgia/production/status") public ResponseEntity getNostalgiaProductionStatus() { var s = buildRunState.status(BuildRunStateService.NOSTALGIA_PROD); diff --git a/src/main/java/org/zaine/app/controller/zone/ZoneController.java b/src/main/java/org/zaine/app/controller/zone/ZoneController.java index 1463c78..f3617d9 100755 --- a/src/main/java/org/zaine/app/controller/zone/ZoneController.java +++ b/src/main/java/org/zaine/app/controller/zone/ZoneController.java @@ -3,12 +3,10 @@ package org.zaine.app.controller.zone; import java.io.IOException; import java.util.Map; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; +import org.zaine.app.common.application.ApplicationException; import org.zaine.app.service.ZoneManifestService; import org.zaine.app.service.ZoneStatusService; @@ -34,7 +32,7 @@ public class ZoneController { try { return manifestService.loadEnrichedManifest(); } catch (IOException ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + throw ApplicationException.failure(ex.getMessage()); } } diff --git a/src/main/java/org/zaine/app/dto/CalibreBookDTO.java b/src/main/java/org/zaine/app/dto/CalibreBookDTO.java deleted file mode 100755 index fdc4180..0000000 --- a/src/main/java/org/zaine/app/dto/CalibreBookDTO.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.zaine.app.dto; - -/** - * Lightweight projection of a Calibre book — just what the - * Mutāla'ah book-picker needs. - */ -public class CalibreBookDTO { - - private final long id; - private final String title; - private final String authors; - - public CalibreBookDTO(long id, String title, String authors) { - this.id = id; - this.title = title; - this.authors = authors != null ? authors : ""; - } - - public long getId() { return id; } - public String getTitle() { return title; } - public String getAuthors() { return authors; } -} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/dto/CreateNoteDTO.java b/src/main/java/org/zaine/app/dto/CreateNoteDTO.java deleted file mode 100755 index 8e4896c..0000000 --- a/src/main/java/org/zaine/app/dto/CreateNoteDTO.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.zaine.app.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class CreateNoteDTO { - - @JsonProperty("author_name") - private String authorName; - - private String content; - - public CreateNoteDTO() {} - - public String getAuthorName() { - return authorName; - } - - public void setAuthorName(String authorName) { - this.authorName = authorName; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } -} diff --git a/src/main/java/org/zaine/app/dto/EveningCheckinRequest.java b/src/main/java/org/zaine/app/dto/EveningCheckinRequest.java deleted file mode 100755 index 291f7de..0000000 --- a/src/main/java/org/zaine/app/dto/EveningCheckinRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.zaine.app.dto; - -import java.time.LocalDate; - -public record EveningCheckinRequest( - LocalDate date, - Integer mood, - Integer stressLevel, - Boolean duties, - Boolean zikr, - Boolean salah, - String reflection, - String bestThingToday) { -} diff --git a/src/main/java/org/zaine/app/dto/MorningCheckinRequest.java b/src/main/java/org/zaine/app/dto/MorningCheckinRequest.java deleted file mode 100755 index 5c12a6e..0000000 --- a/src/main/java/org/zaine/app/dto/MorningCheckinRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.zaine.app.dto; - -import java.math.BigDecimal; -import java.time.LocalDate; - -public record MorningCheckinRequest( - LocalDate date, - BigDecimal sleepHours, - Integer energyLevel, - Integer mood, - Boolean fajr, - Boolean quran, - Boolean exercise, - String note) { -} diff --git a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java deleted file mode 100755 index 195c94f..0000000 --- a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.zaine.app.dto; - -import java.time.LocalDate; -import java.time.OffsetDateTime; -import java.util.List; - -public final class PeriodCompanionDTO { - private PeriodCompanionDTO() {} - - public record SymptomDTO(Long id, String name) {} - - public record DailyLogDTO( - Long id, - LocalDate date, - String flowLevel, - String mood, - String energyLevel, - String notes, - List symptoms, - OffsetDateTime createdAt, - OffsetDateTime updatedAt) {} - - public record DailyLogRequest( - LocalDate date, - String flowLevel, - String mood, - String energyLevel, - String notes, - List symptoms) {} - - public record PeriodCycleDTO( - Long id, - LocalDate startDate, - LocalDate endDate, - Integer cycleLength, - Integer periodLength, - OffsetDateTime createdAt, - OffsetDateTime updatedAt) {} - - public record PeriodCycleRequest(LocalDate startDate, LocalDate endDate) {} - - public record PeriodEntryRequest(LocalDate startDate, LocalDate endDate, String flowLevel) {} - - public record PredictionDTO( - Long id, - OffsetDateTime generatedAt, - LocalDate predictedPeriodDate, - LocalDate predictedOvulationDate, - LocalDate fertileWindowStart, - LocalDate fertileWindowEnd, - int averageCycleLength, - int averagePeriodLength) {} - - public record CurrentCycleDTO( - Integer day, - String phase, - PeriodCycleDTO cycle, - int averageCycleLength, - int averagePeriodLength) {} - - public record DashboardDTO( - CurrentCycleDTO currentCycle, - PredictionDTO prediction, - DailyLogDTO today, - SettingsDTO settings, - List supportMessages, - List recentPartnerMessages) {} - - public record CalendarDayDTO( - LocalDate date, - boolean periodDay, - boolean predictedPeriodDay, - boolean fertileWindow, - boolean ovulationDay, - List symptoms, - DailyLogDTO log) {} - - public record CycleTrendDTO(LocalDate startDate, Integer cycleLength, Integer periodLength) {} - public record SymptomTrendDTO(String symptom, long count) {} - public record MoodTrendDTO(LocalDate date, String mood) {} - - public record InsightsDTO( - List cycleTrends, - List symptomTrends, - List moodTrends) {} - - public record SupportMessageDTO(Long id, String authorName, String message, OffsetDateTime createdAt) {} - public record SupportMessageRequest(String authorName, String message) {} - public record CreateSymptomRequest(String name) {} - public record SettingsDTO(int averageCycleLength, int averagePeriodLength, OffsetDateTime updatedAt) {} - public record SettingsRequest(Integer averageCycleLength, Integer averagePeriodLength) {} - public record FastingLogDTO(Long id, LocalDate fastDate, String status, String notes, OffsetDateTime createdAt, OffsetDateTime updatedAt) {} - public record FastingLogRequest(LocalDate fastDate, String status, String notes) {} - public record ReflectionDTO(Long id, LocalDate date, String prompt, String response, OffsetDateTime createdAt, OffsetDateTime updatedAt) {} - public record ReflectionRequest(LocalDate date, String prompt, String response) {} - public record WellbeingDTO( - String phase, - String bodyInsight, - String supportiveAction, - String spiritualReminder, - String fiqhNote, - ReflectionDTO todayReflection, - List recentReflections, - List fastingLogs, - long missedFastCount, - long madeUpFastCount) {} -} diff --git a/src/main/java/org/zaine/app/dto/TodayStatusDTO.java b/src/main/java/org/zaine/app/dto/TodayStatusDTO.java deleted file mode 100755 index 8765817..0000000 --- a/src/main/java/org/zaine/app/dto/TodayStatusDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.zaine.app.dto; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.OffsetDateTime; - -public record TodayStatusDTO( - LocalDate date, - boolean morningDone, - boolean eveningDone, - BigDecimal sleepHours, - Integer morningEnergyLevel, - Integer morningMoodLevel, - Integer eveningMoodLevel, - Integer stressLevel, - Boolean fajr, - Boolean quran, - Boolean exercise, - Boolean duties, - Boolean zikr, - Boolean salah, - String reflection, - OffsetDateTime morningCompletedAt, - OffsetDateTime eveningCompletedAt) { -} diff --git a/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java b/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java index 53568d9..686fdfb 100755 --- a/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java @@ -1,19 +1,23 @@ package org.zaine.app.exception; import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.NoHandlerFoundException; +import org.zaine.app.common.application.ApplicationException; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity> handleNotFound(HttpServletRequest request) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body( @@ -21,24 +25,30 @@ public class GlobalExceptionHandler { ); } - @ExceptionHandler(ResponseStatusException.class) - public ResponseEntity> handleResponseStatus( - ResponseStatusException ex, + @ExceptionHandler(ApplicationException.class) + public ResponseEntity> handleApplication( + ApplicationException ex, HttpServletRequest request) { - HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); - if (status == null) { - status = HttpStatus.INTERNAL_SERVER_ERROR; + HttpStatus status = switch (ex.kind()) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case PAYLOAD_TOO_LARGE -> HttpStatus.PAYLOAD_TOO_LARGE; + case FAILURE -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + if (status.is4xxClientError()) { + log.debug("Request rejected: status={}, path={}, reason={}", status.value(), request.getRequestURI(), ex.getMessage()); + } else { + log.warn("Application operation failed: path={}, reason={}", request.getRequestURI(), ex.getMessage()); } - String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase(); - return ResponseEntity.status(status).body( - Map.of("message", reason, "path", request.getRequestURI()) - ); + return ResponseEntity.status(status).body(Map.of("message", ex.getMessage(), "path", request.getRequestURI())); } @ExceptionHandler(DataAccessException.class) public ResponseEntity> handleDataAccess( DataAccessException ex, HttpServletRequest request) { + log.error("Database operation failed for path {}", request.getRequestURI(), ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( Map.of( "message", "Database error", diff --git a/src/main/java/org/zaine/app/model/DailyCheckin.java b/src/main/java/org/zaine/app/model/DailyCheckin.java deleted file mode 100755 index 149f2de..0000000 --- a/src/main/java/org/zaine/app/model/DailyCheckin.java +++ /dev/null @@ -1,130 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.OffsetDateTime; - -@Entity -@Table(name = "daily_checkins") -public class DailyCheckin { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private LocalDate date; - - @Column(name = "sleep_hours", precision = 4, scale = 2) - private BigDecimal sleepHours; - - @Column(name = "morning_energy_level") - private Integer morningEnergyLevel; - - @Column(name = "morning_mood_level") - private Integer morningMoodLevel; - - @Column(name = "morning_note") - private String morningNote; - - @Column(name = "fajr") - private Boolean fajr; - - @Column(name = "quran") - private Boolean quran; - - @Column(name = "exercise") - private Boolean exercise; - - @Column(name = "morning_completed_at") - private OffsetDateTime morningCompletedAt; - - @Column(name = "evening_mood_level") - private Integer eveningMoodLevel; - - @Column(name = "stress_level") - private Integer stressLevel; - - @Column(name = "reflection", columnDefinition = "text") - private String reflection; - - @Column(name = "best_thing_today") - private String bestThingToday; - - @Column(name = "duties") - private Boolean duties; - - @Column(name = "zikr") - private Boolean zikr; - - @Column(name = "salah") - private Boolean salah; - - @Column(name = "evening_completed_at") - private OffsetDateTime eveningCompletedAt; - - @Column(name = "created_at", nullable = false, updatable = false) - private OffsetDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - void onInsert() { - OffsetDateTime now = OffsetDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public LocalDate getDate() { return date; } - public void setDate(LocalDate date) { this.date = date; } - public BigDecimal getSleepHours() { return sleepHours; } - public void setSleepHours(BigDecimal sleepHours) { this.sleepHours = sleepHours; } - public Integer getMorningEnergyLevel() { return morningEnergyLevel; } - public void setMorningEnergyLevel(Integer morningEnergyLevel) { this.morningEnergyLevel = morningEnergyLevel; } - public Integer getMorningMoodLevel() { return morningMoodLevel; } - public void setMorningMoodLevel(Integer morningMoodLevel) { this.morningMoodLevel = morningMoodLevel; } - public String getMorningNote() { return morningNote; } - public void setMorningNote(String morningNote) { this.morningNote = morningNote; } - public Boolean getFajr() { return fajr; } - public void setFajr(Boolean fajr) { this.fajr = fajr; } - public Boolean getQuran() { return quran; } - public void setQuran(Boolean quran) { this.quran = quran; } - public Boolean getExercise() { return exercise; } - public void setExercise(Boolean exercise) { this.exercise = exercise; } - public OffsetDateTime getMorningCompletedAt() { return morningCompletedAt; } - public void setMorningCompletedAt(OffsetDateTime morningCompletedAt) { this.morningCompletedAt = morningCompletedAt; } - public Integer getEveningMoodLevel() { return eveningMoodLevel; } - public void setEveningMoodLevel(Integer eveningMoodLevel) { this.eveningMoodLevel = eveningMoodLevel; } - public Integer getStressLevel() { return stressLevel; } - public void setStressLevel(Integer stressLevel) { this.stressLevel = stressLevel; } - public String getReflection() { return reflection; } - public void setReflection(String reflection) { this.reflection = reflection; } - public String getBestThingToday() { return bestThingToday; } - public void setBestThingToday(String bestThingToday) { this.bestThingToday = bestThingToday; } - public Boolean getDuties() { return duties; } - public void setDuties(Boolean duties) { this.duties = duties; } - public Boolean getZikr() { return zikr; } - public void setZikr(Boolean zikr) { this.zikr = zikr; } - public Boolean getSalah() { return salah; } - public void setSalah(Boolean salah) { this.salah = salah; } - public OffsetDateTime getEveningCompletedAt() { return eveningCompletedAt; } - public void setEveningCompletedAt(OffsetDateTime eveningCompletedAt) { this.eveningCompletedAt = eveningCompletedAt; } - public OffsetDateTime getCreatedAt() { return createdAt; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } -} diff --git a/src/main/java/org/zaine/app/model/Notes.java b/src/main/java/org/zaine/app/model/Notes.java deleted file mode 100755 index 480c261..0000000 --- a/src/main/java/org/zaine/app/model/Notes.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.GeneratedValue; - -import java.time.Instant; -import java.util.UUID; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.hibernate.annotations.CreationTimestamp; - -@Entity -@Table(name = "public_notes") -public class Notes { - - @Id - @GeneratedValue - @Column(name="id") - private UUID id; - - @Column(name="content") - private String content; - - @JsonProperty("author_name") - @Column(name="author_name") - private String authorName; - - @JsonProperty("created_at") - - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - - public UUID getId() { - return id; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public String getAuthorName() { - return authorName; - } - - public void setAuthorName(String authorName) { - this.authorName = authorName; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } - - -} diff --git a/src/main/java/org/zaine/app/model/PeriodCycle.java b/src/main/java/org/zaine/app/model/PeriodCycle.java deleted file mode 100755 index 4523fb1..0000000 --- a/src/main/java/org/zaine/app/model/PeriodCycle.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; - -import java.time.LocalDate; -import java.time.OffsetDateTime; - -@Entity -@Table(name = "pc_cycles") -public class PeriodCycle { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "start_date", nullable = false, unique = true) - private LocalDate startDate; - - @Column(name = "end_date") - private LocalDate endDate; - - @Column(name = "cycle_length") - private Integer cycleLength; - - @Column(name = "period_length") - private Integer periodLength; - - @Column(name = "created_at", nullable = false, updatable = false) - private OffsetDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - void onInsert() { - OffsetDateTime now = OffsetDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public LocalDate getStartDate() { return startDate; } - public void setStartDate(LocalDate startDate) { this.startDate = startDate; } - public LocalDate getEndDate() { return endDate; } - public void setEndDate(LocalDate endDate) { this.endDate = endDate; } - public Integer getCycleLength() { return cycleLength; } - public void setCycleLength(Integer cycleLength) { this.cycleLength = cycleLength; } - public Integer getPeriodLength() { return periodLength; } - public void setPeriodLength(Integer periodLength) { this.periodLength = periodLength; } - public OffsetDateTime getCreatedAt() { return createdAt; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodDailyLog.java b/src/main/java/org/zaine/app/model/PeriodDailyLog.java deleted file mode 100755 index 23a184f..0000000 --- a/src/main/java/org/zaine/app/model/PeriodDailyLog.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.JoinTable; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; - -import java.time.LocalDate; -import java.time.OffsetDateTime; -import java.util.LinkedHashSet; -import java.util.Set; - -@Entity -@Table(name = "pc_daily_logs") -public class PeriodDailyLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private LocalDate date; - - @Column(name = "flow_level") - private String flowLevel; - - @Column(name = "mood") - private String mood; - - @Column(name = "energy_level") - private String energyLevel; - - @Column(name = "notes", columnDefinition = "text") - private String notes; - - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable( - name = "pc_daily_log_symptoms", - joinColumns = @JoinColumn(name = "daily_log_id"), - inverseJoinColumns = @JoinColumn(name = "symptom_id")) - private Set symptoms = new LinkedHashSet<>(); - - @Column(name = "created_at", nullable = false, updatable = false) - private OffsetDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - void onInsert() { - OffsetDateTime now = OffsetDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public LocalDate getDate() { return date; } - public void setDate(LocalDate date) { this.date = date; } - public String getFlowLevel() { return flowLevel; } - public void setFlowLevel(String flowLevel) { this.flowLevel = flowLevel; } - public String getMood() { return mood; } - public void setMood(String mood) { this.mood = mood; } - public String getEnergyLevel() { return energyLevel; } - public void setEnergyLevel(String energyLevel) { this.energyLevel = energyLevel; } - public String getNotes() { return notes; } - public void setNotes(String notes) { this.notes = notes; } - public Set getSymptoms() { return symptoms; } - public void setSymptoms(Set symptoms) { this.symptoms = symptoms; } - public OffsetDateTime getCreatedAt() { return createdAt; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodDailyReflection.java b/src/main/java/org/zaine/app/model/PeriodDailyReflection.java deleted file mode 100755 index df8ac65..0000000 --- a/src/main/java/org/zaine/app/model/PeriodDailyReflection.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; - -import java.time.LocalDate; -import java.time.OffsetDateTime; - -@Entity -@Table(name = "pc_daily_reflections") -public class PeriodDailyReflection { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private LocalDate date; - - @Column(nullable = false) - private String prompt; - - @Column(name = "response", columnDefinition = "text") - private String response; - - @Column(name = "created_at", nullable = false, updatable = false) - private OffsetDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - void onInsert() { - OffsetDateTime now = OffsetDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public LocalDate getDate() { return date; } - public void setDate(LocalDate date) { this.date = date; } - public String getPrompt() { return prompt; } - public void setPrompt(String prompt) { this.prompt = prompt; } - public String getResponse() { return response; } - public void setResponse(String response) { this.response = response; } - public OffsetDateTime getCreatedAt() { return createdAt; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodFastingLog.java b/src/main/java/org/zaine/app/model/PeriodFastingLog.java deleted file mode 100755 index 91b6e6b..0000000 --- a/src/main/java/org/zaine/app/model/PeriodFastingLog.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; - -import java.time.LocalDate; -import java.time.OffsetDateTime; - -@Entity -@Table(name = "pc_fasting_logs") -public class PeriodFastingLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "fast_date", nullable = false, unique = true) - private LocalDate fastDate; - - @Column(nullable = false) - private String status; - - @Column(name = "notes", columnDefinition = "text") - private String notes; - - @Column(name = "created_at", nullable = false, updatable = false) - private OffsetDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - void onInsert() { - OffsetDateTime now = OffsetDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public LocalDate getFastDate() { return fastDate; } - public void setFastDate(LocalDate fastDate) { this.fastDate = fastDate; } - public String getStatus() { return status; } - public void setStatus(String status) { this.status = status; } - public String getNotes() { return notes; } - public void setNotes(String notes) { this.notes = notes; } - public OffsetDateTime getCreatedAt() { return createdAt; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java b/src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java deleted file mode 100755 index faa474d..0000000 --- a/src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; - -import java.time.LocalDate; -import java.time.OffsetDateTime; - -@Entity -@Table(name = "pc_prediction_snapshots") -public class PeriodPredictionSnapshot { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "generated_at", nullable = false, updatable = false) - private OffsetDateTime generatedAt; - - @Column(name = "predicted_period_date", nullable = false) - private LocalDate predictedPeriodDate; - - @Column(name = "predicted_ovulation_date", nullable = false) - private LocalDate predictedOvulationDate; - - @Column(name = "fertile_window_start", nullable = false) - private LocalDate fertileWindowStart; - - @Column(name = "fertile_window_end", nullable = false) - private LocalDate fertileWindowEnd; - - @PrePersist - void onInsert() { - this.generatedAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public OffsetDateTime getGeneratedAt() { return generatedAt; } - public void setGeneratedAt(OffsetDateTime generatedAt) { this.generatedAt = generatedAt; } - public LocalDate getPredictedPeriodDate() { return predictedPeriodDate; } - public void setPredictedPeriodDate(LocalDate predictedPeriodDate) { this.predictedPeriodDate = predictedPeriodDate; } - public LocalDate getPredictedOvulationDate() { return predictedOvulationDate; } - public void setPredictedOvulationDate(LocalDate predictedOvulationDate) { this.predictedOvulationDate = predictedOvulationDate; } - public LocalDate getFertileWindowStart() { return fertileWindowStart; } - public void setFertileWindowStart(LocalDate fertileWindowStart) { this.fertileWindowStart = fertileWindowStart; } - public LocalDate getFertileWindowEnd() { return fertileWindowEnd; } - public void setFertileWindowEnd(LocalDate fertileWindowEnd) { this.fertileWindowEnd = fertileWindowEnd; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodSettings.java b/src/main/java/org/zaine/app/model/PeriodSettings.java deleted file mode 100755 index 37ba050..0000000 --- a/src/main/java/org/zaine/app/model/PeriodSettings.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import jakarta.persistence.Table; - -import java.time.OffsetDateTime; - -@Entity -@Table(name = "pc_settings") -public class PeriodSettings { - public static final Short SINGLETON_ID = 1; - - @Id - private Short id = SINGLETON_ID; - - @Column(name = "average_cycle_length", nullable = false) - private Integer averageCycleLength = 28; - - @Column(name = "average_period_length", nullable = false) - private Integer averagePeriodLength = 5; - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt; - - @PrePersist - void onInsert() { - this.updatedAt = OffsetDateTime.now(); - } - - @PreUpdate - void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - public Short getId() { return id; } - public void setId(Short id) { this.id = id; } - public Integer getAverageCycleLength() { return averageCycleLength; } - public void setAverageCycleLength(Integer averageCycleLength) { this.averageCycleLength = averageCycleLength; } - public Integer getAveragePeriodLength() { return averagePeriodLength; } - public void setAveragePeriodLength(Integer averagePeriodLength) { this.averagePeriodLength = averagePeriodLength; } - public OffsetDateTime getUpdatedAt() { return updatedAt; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodSupportMessage.java b/src/main/java/org/zaine/app/model/PeriodSupportMessage.java deleted file mode 100755 index 72f5ef6..0000000 --- a/src/main/java/org/zaine/app/model/PeriodSupportMessage.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; - -import java.time.OffsetDateTime; - -@Entity -@Table(name = "pc_support_messages") -public class PeriodSupportMessage { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "author_name") - private String authorName; - - @Column(name = "message", nullable = false, columnDefinition = "text") - private String message; - - @Column(name = "created_at", nullable = false, updatable = false) - private OffsetDateTime createdAt; - - @PrePersist - void onInsert() { - this.createdAt = OffsetDateTime.now(); - } - - public Long getId() { return id; } - public String getAuthorName() { return authorName; } - public void setAuthorName(String authorName) { this.authorName = authorName; } - public String getMessage() { return message; } - public void setMessage(String message) { this.message = message; } - public OffsetDateTime getCreatedAt() { return createdAt; } -} diff --git a/src/main/java/org/zaine/app/model/PeriodSymptom.java b/src/main/java/org/zaine/app/model/PeriodSymptom.java deleted file mode 100755 index c92170d..0000000 --- a/src/main/java/org/zaine/app/model/PeriodSymptom.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.zaine.app.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -@Entity -@Table(name = "pc_symptoms") -public class PeriodSymptom { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String name; - - public Long getId() { return id; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } -} diff --git a/src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java b/src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java new file mode 100644 index 0000000..26f4052 --- /dev/null +++ b/src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java @@ -0,0 +1,50 @@ +package org.zaine.app.notes.adapter.in.web; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +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.RestController; +import org.zaine.app.notes.application.port.in.NotesUseCase; +import org.zaine.app.notes.domain.Note; + +@RestController +@RequestMapping("/api/notes") +@Tag(name = "Notes", description = "Operations related to notes") +public class NotesController { + private final NotesUseCase notes; + + public NotesController(NotesUseCase notes) { + this.notes = notes; + } + + @Operation(summary = "Get all notes") + @GetMapping + public List getAllNotes() { + return notes.getAll().stream().map(NoteResponse::from).toList(); + } + + @Operation(summary = "Create a note object") + @PostMapping(consumes = "application/json") + public NoteResponse createNote(@RequestBody CreateNoteRequest request) { + return NoteResponse.from(notes.create(request.content(), request.authorName())); + } + + public record CreateNoteRequest(@JsonProperty("author_name") String authorName, String content) {} + + public record NoteResponse( + UUID id, + String content, + @JsonProperty("author_name") String authorName, + @JsonProperty("created_at") Instant createdAt) { + static NoteResponse from(Note note) { + return new NoteResponse(note.id(), note.content(), note.authorName(), note.createdAt()); + } + } +} diff --git a/src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java b/src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java new file mode 100644 index 0000000..3c11d1b --- /dev/null +++ b/src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java @@ -0,0 +1,40 @@ +package org.zaine.app.notes.adapter.out.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Table(name = "public_notes") +class NoteJpaEntity { + @Id + @GeneratedValue + private UUID id; + + @Column(name = "content") + private String content; + + @Column(name = "author_name") + private String authorName; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected NoteJpaEntity() {} + + NoteJpaEntity(String content, String authorName) { + this.content = content; + this.authorName = authorName; + } + + UUID id() { return id; } + String content() { return content; } + String authorName() { return authorName; } + Instant createdAt() { return createdAt; } +} diff --git a/src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java b/src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java new file mode 100644 index 0000000..1de8db6 --- /dev/null +++ b/src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java @@ -0,0 +1,29 @@ +package org.zaine.app.notes.adapter.out.persistence; + +import java.util.List; +import org.springframework.stereotype.Component; +import org.zaine.app.notes.application.port.out.NoteRepositoryPort; +import org.zaine.app.notes.domain.Note; + +@Component +class NotePersistenceAdapter implements NoteRepositoryPort { + private final SpringDataNoteRepository repository; + + NotePersistenceAdapter(SpringDataNoteRepository repository) { + this.repository = repository; + } + + @Override + public List findAll() { + return repository.findAll().stream().map(NotePersistenceAdapter::toDomain).toList(); + } + + @Override + public Note save(Note note) { + return toDomain(repository.save(new NoteJpaEntity(note.content(), note.authorName()))); + } + + private static Note toDomain(NoteJpaEntity entity) { + return new Note(entity.id(), entity.content(), entity.authorName(), entity.createdAt()); + } +} diff --git a/src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java b/src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java new file mode 100644 index 0000000..9008c71 --- /dev/null +++ b/src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java @@ -0,0 +1,6 @@ +package org.zaine.app.notes.adapter.out.persistence; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +interface SpringDataNoteRepository extends JpaRepository {} diff --git a/src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java b/src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java new file mode 100644 index 0000000..ca6d170 --- /dev/null +++ b/src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java @@ -0,0 +1,9 @@ +package org.zaine.app.notes.application.port.in; + +import java.util.List; +import org.zaine.app.notes.domain.Note; + +public interface NotesUseCase { + List getAll(); + Note create(String content, String authorName); +} diff --git a/src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java b/src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java new file mode 100644 index 0000000..ff8b37e --- /dev/null +++ b/src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java @@ -0,0 +1,9 @@ +package org.zaine.app.notes.application.port.out; + +import java.util.List; +import org.zaine.app.notes.domain.Note; + +public interface NoteRepositoryPort { + List findAll(); + Note save(Note note); +} diff --git a/src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java b/src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java new file mode 100644 index 0000000..74f9ac1 --- /dev/null +++ b/src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java @@ -0,0 +1,26 @@ +package org.zaine.app.notes.application.service; + +import java.util.List; +import org.springframework.stereotype.Service; +import org.zaine.app.notes.application.port.in.NotesUseCase; +import org.zaine.app.notes.application.port.out.NoteRepositoryPort; +import org.zaine.app.notes.domain.Note; + +@Service +public class NotesApplicationService implements NotesUseCase { + private final NoteRepositoryPort repository; + + public NotesApplicationService(NoteRepositoryPort repository) { + this.repository = repository; + } + + @Override + public List getAll() { + return repository.findAll(); + } + + @Override + public Note create(String content, String authorName) { + return repository.save(Note.create(content, authorName)); + } +} diff --git a/src/main/java/org/zaine/app/notes/domain/Note.java b/src/main/java/org/zaine/app/notes/domain/Note.java new file mode 100644 index 0000000..8d148f9 --- /dev/null +++ b/src/main/java/org/zaine/app/notes/domain/Note.java @@ -0,0 +1,10 @@ +package org.zaine.app.notes.domain; + +import java.time.Instant; +import java.util.UUID; + +public record Note(UUID id, String content, String authorName, Instant createdAt) { + public static Note create(String content, String authorName) { + return new Note(null, content, authorName, null); + } +} diff --git a/src/main/java/org/zaine/app/repositories/DailyCheckinRepository.java b/src/main/java/org/zaine/app/repositories/DailyCheckinRepository.java deleted file mode 100755 index daac2e7..0000000 --- a/src/main/java/org/zaine/app/repositories/DailyCheckinRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.DailyCheckin; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public interface DailyCheckinRepository extends JpaRepository { - Optional findByDate(LocalDate date); - List findByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to); -} diff --git a/src/main/java/org/zaine/app/repositories/NotesRepository.java b/src/main/java/org/zaine/app/repositories/NotesRepository.java deleted file mode 100755 index 1315a84..0000000 --- a/src/main/java/org/zaine/app/repositories/NotesRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.UUID; -import org.zaine.app.model.Notes; - -public interface NotesRepository extends JpaRepository { - -} - - diff --git a/src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java b/src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java deleted file mode 100755 index a902afd..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodCycle; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public interface PeriodCycleRepository extends JpaRepository { - Optional findByStartDate(LocalDate startDate); - Optional findFirstByOrderByStartDateDesc(); - Optional findFirstByStartDateLessThanOrderByStartDateDesc(LocalDate startDate); - List findByStartDateBetweenOrderByStartDateAsc(LocalDate from, LocalDate to); - List findTop12ByEndDateIsNotNullOrderByStartDateDesc(); - List findAllByOrderByStartDateAsc(); -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java b/src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java deleted file mode 100755 index 052e589..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodDailyLog; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public interface PeriodDailyLogRepository extends JpaRepository { - Optional findByDate(LocalDate date); - List findByDateBetweenOrderByDateAsc(LocalDate from, LocalDate to); - List findByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to); -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodDailyReflectionRepository.java b/src/main/java/org/zaine/app/repositories/PeriodDailyReflectionRepository.java deleted file mode 100755 index dd95330..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodDailyReflectionRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodDailyReflection; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public interface PeriodDailyReflectionRepository extends JpaRepository { - Optional findByDate(LocalDate date); - List findTop14ByOrderByDateDesc(); -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodFastingLogRepository.java b/src/main/java/org/zaine/app/repositories/PeriodFastingLogRepository.java deleted file mode 100755 index 0788da7..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodFastingLogRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodFastingLog; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public interface PeriodFastingLogRepository extends JpaRepository { - Optional findByFastDate(LocalDate fastDate); - List findTop50ByOrderByFastDateDesc(); - long countByStatus(String status); -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java b/src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java deleted file mode 100755 index 3c0344f..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodPredictionSnapshot; - -import java.util.Optional; - -public interface PeriodPredictionSnapshotRepository extends JpaRepository { - Optional findFirstByOrderByGeneratedAtDesc(); -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodSettingsRepository.java b/src/main/java/org/zaine/app/repositories/PeriodSettingsRepository.java deleted file mode 100755 index a486091..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodSettingsRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodSettings; - -public interface PeriodSettingsRepository extends JpaRepository { -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java b/src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java deleted file mode 100755 index 6dcd89d..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodSupportMessage; - -import java.util.List; - -public interface PeriodSupportMessageRepository extends JpaRepository { - List findTop20ByOrderByCreatedAtDesc(); -} diff --git a/src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java b/src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java deleted file mode 100755 index 7250599..0000000 --- a/src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.zaine.app.repositories; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.zaine.app.model.PeriodSymptom; - -import java.util.List; -import java.util.Optional; - -public interface PeriodSymptomRepository extends JpaRepository { - Optional findByNameIgnoreCase(String name); - List findAllByOrderByNameAsc(); -} diff --git a/src/main/java/org/zaine/app/security/JwtAuthFilter.java b/src/main/java/org/zaine/app/security/JwtAuthFilter.java deleted file mode 100755 index 9b5d4d1..0000000 --- a/src/main/java/org/zaine/app/security/JwtAuthFilter.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.zaine.app.security; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerExecutionChain; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; - -import java.io.IOException; -import java.util.List; - -@Component -public class JwtAuthFilter extends OncePerRequestFilter { - - private final JwtUtil jwtUtil; - private final RequestMappingHandlerMapping requestMappingHandlerMapping; - - public JwtAuthFilter(JwtUtil jwtUtil, RequestMappingHandlerMapping requestMappingHandlerMapping) { - this.jwtUtil = jwtUtil; - this.requestMappingHandlerMapping = requestMappingHandlerMapping; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - if (!endpointRequiresAuth(request)) { - filterChain.doFilter(request, response); - return; - } - - String authHeader = request.getHeader("Authorization"); - - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header"); - return; - } - - String token = authHeader.substring(7); - - if (!jwtUtil.isTokenValid(token)) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token"); - return; - } - - String username = jwtUtil.extractUsername(token); - var auth = new UsernamePasswordAuthenticationToken(username, null, List.of()); - auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(auth); - - filterChain.doFilter(request, response); - } - - private boolean endpointRequiresAuth(HttpServletRequest request) { - try { - HandlerExecutionChain chain = requestMappingHandlerMapping.getHandler(request); - if (chain == null) return false; - Object handler = chain.getHandler(); - if (!(handler instanceof HandlerMethod method)) return false; - - return method.hasMethodAnnotation(RequiresAuth.class) - || method.getBeanType().isAnnotationPresent(RequiresAuth.class); - } catch (Exception e) { - return false; - } - } -} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/security/JwtUtil.java b/src/main/java/org/zaine/app/security/JwtUtil.java index 5160fd9..3725f61 100755 --- a/src/main/java/org/zaine/app/security/JwtUtil.java +++ b/src/main/java/org/zaine/app/security/JwtUtil.java @@ -11,11 +11,15 @@ import java.util.Date; @Component public class JwtUtil { - @Value("${jwt.secret}") - private String secret; + private final String secret; + private final long expirationMs; - @Value("${jwt.expiration-ms:86400000}") - private long expirationMs; + public JwtUtil( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration-ms:86400000}") long expirationMs) { + this.secret = secret; + this.expirationMs = expirationMs; + } private Key getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes()); @@ -50,4 +54,4 @@ public class JwtUtil { .parseClaimsJws(token) .getBody(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/security/RequiresAuth.java b/src/main/java/org/zaine/app/security/RequiresAuth.java deleted file mode 100755 index a7d61d0..0000000 --- a/src/main/java/org/zaine/app/security/RequiresAuth.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.zaine.app.security; - -import java.lang.annotation.*; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface RequiresAuth { -} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/security/SecurityConfig.java b/src/main/java/org/zaine/app/security/SecurityConfig.java index 6c98e48..f2be59e 100755 --- a/src/main/java/org/zaine/app/security/SecurityConfig.java +++ b/src/main/java/org/zaine/app/security/SecurityConfig.java @@ -13,17 +13,14 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity public class SecurityConfig { - private final JwtAuthFilter jwtAuthFilter; private final UserDetailsServiceImpl userDetailsService; - public SecurityConfig(JwtAuthFilter jwtAuthFilter, UserDetailsServiceImpl userDetailsService) { - this.jwtAuthFilter = jwtAuthFilter; + public SecurityConfig(UserDetailsServiceImpl userDetailsService) { this.userDetailsService = userDetailsService; } @@ -40,8 +37,7 @@ public class SecurityConfig { "/swagger-ui.html" ).permitAll() .anyRequest().permitAll() - ) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + ); return http.build(); } @@ -63,4 +59,4 @@ public class SecurityConfig { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/service/CalendarService.java b/src/main/java/org/zaine/app/service/CalendarService.java index b778c4a..1255f01 100755 --- a/src/main/java/org/zaine/app/service/CalendarService.java +++ b/src/main/java/org/zaine/app/service/CalendarService.java @@ -1,8 +1,7 @@ package org.zaine.app.service; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; +import org.zaine.app.common.application.ApplicationException; import org.zaine.app.dto.CalendarEventDTO; import org.zaine.app.dto.CreateCalendarEventDTO; import org.zaine.app.model.CalendarEvent; @@ -36,7 +35,7 @@ public class CalendarService { public List getEventsForRange(LocalDate from, LocalDate to) { if (to.isBefore(from)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on or after from"); + throw ApplicationException.badRequest("to must be on or after from"); } ZoneId zone = ZoneId.systemDefault(); @@ -52,19 +51,19 @@ public class CalendarService { public CalendarEventDTO getEvent(Long id) { CalendarEvent event = calendarEventRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Calendar event not found")); + .orElseThrow(() -> ApplicationException.notFound("Calendar event not found")); return toDto(event); } public CalendarEventDTO createEvent(CreateCalendarEventDTO dto) { if (dto.title() == null || dto.title().isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required"); + throw ApplicationException.badRequest("title is required"); } if (dto.startsAt() == null || dto.endsAt() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "startsAt and endsAt are required"); + throw ApplicationException.badRequest("startsAt and endsAt are required"); } if (!dto.endsAt().isAfter(dto.startsAt())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "endsAt must be after startsAt"); + throw ApplicationException.badRequest("endsAt must be after startsAt"); } CalendarEvent event = new CalendarEvent(); diff --git a/src/main/java/org/zaine/app/service/CalendarSyncService.java b/src/main/java/org/zaine/app/service/CalendarSyncService.java index 1c6cd05..f0c927d 100755 --- a/src/main/java/org/zaine/app/service/CalendarSyncService.java +++ b/src/main/java/org/zaine/app/service/CalendarSyncService.java @@ -9,6 +9,8 @@ import net.fortuna.ical4j.model.Property; import net.fortuna.ical4j.model.component.CalendarComponent; import net.fortuna.ical4j.model.component.VEvent; import net.fortuna.ical4j.model.property.DateProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,13 +42,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.logging.Level; -import java.util.logging.Logger; @Service public class CalendarSyncService { - private static final Logger logger = Logger.getLogger(CalendarSyncService.class.getName()); + private static final Logger log = LoggerFactory.getLogger(CalendarSyncService.class); private final CalendarEventRepository calendarEventRepository; private final CalendarSyncProperties properties; @@ -68,9 +68,9 @@ public class CalendarSyncService { public void scheduledSync() { try { CalendarSyncResultDTO result = sync(); - logger.info("Calendar sync complete: " + result.eventCount() + " events from " + result.sourceCount() + " sources"); + log.info("Calendar sync complete: eventCount={}, sourceCount={}", result.eventCount(), result.sourceCount()); } catch (Exception ex) { - logger.log(Level.WARNING, "Calendar sync failed", ex); + log.warn("Scheduled calendar sync failed: {}", ex.getMessage(), ex); } } @@ -90,7 +90,7 @@ public class CalendarSyncService { syncedSources.add(source.name()); eventCount += events.size(); } catch (Exception ex) { - logger.log(Level.WARNING, "Failed to sync calendar source " + source.name(), ex); + log.warn("Failed to sync calendar source {}: {}", source.name(), ex.getMessage(), ex); errors.add(source.name() + ": " + ex.getMessage()); } } @@ -171,7 +171,7 @@ public class CalendarSyncService { for (CalendarEvent event : events) { CalendarEvent duplicate = uniqueEvents.putIfAbsent(event.getExternalId(), event); if (duplicate != null) { - logger.info("Skipping duplicate calendar event from " + event.getSource() + " at " + event.getStartsAt()); + log.debug("Skipping duplicate calendar event from source {} at {}", event.getSource(), event.getStartsAt()); } } return List.copyOf(uniqueEvents.values()); @@ -208,7 +208,7 @@ public class CalendarSyncService { try { periods = event.calculateRecurrenceSet(syncWindow); } catch (RuntimeException ex) { - logger.log(Level.WARNING, "Failed to expand recurrence for " + uid + "; using original event only", ex); + log.warn("Failed to expand recurrence for event {}; using original event only: {}", uid, ex.getMessage()); periods = Set.of(); } diff --git a/src/main/java/org/zaine/app/service/CalibreService.java b/src/main/java/org/zaine/app/service/CalibreService.java deleted file mode 100755 index c569352..0000000 --- a/src/main/java/org/zaine/app/service/CalibreService.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.zaine.app.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.zaine.app.dto.CalibreBookDTO; - -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.util.ArrayList; -import java.util.List; - -/** - * Reads book metadata directly from the Calibre SQLite database. - * - * The path is injected from application.properties: - * calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db - * - * We open a fresh connection per call — the Calibre DB is local, small, - * and this service is called infrequently (page load only), so connection - * pooling is not worth the complexity here. - */ -@Service -public class CalibreService { - - private static final Logger log = LoggerFactory.getLogger(CalibreService.class); - - @Value("${calibre.db.path}") - private String calibreDbPath; - - private static final String QUERY = """ - SELECT - b.id AS book_id, - b.title AS title, - GROUP_CONCAT(a.name, ', ') AS authors - FROM books b - LEFT JOIN books_authors_link bal ON b.id = bal.book - LEFT JOIN authors a ON bal.author = a.id - GROUP BY b.id - ORDER BY b.title COLLATE NOCASE - """; - - public List getAllBooks() { - List books = new ArrayList<>(); - String jdbcUrl = "jdbc:sqlite:" + calibreDbPath; - - try (var conn = DriverManager.getConnection(jdbcUrl); - var stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery(QUERY)) { - - while (rs.next()) { - books.add(new CalibreBookDTO( - rs.getLong("book_id"), - rs.getString("title"), - rs.getString("authors") - )); - } - - } catch (Exception e) { - log.error("Failed to read Calibre database at {}: {}", calibreDbPath, e.getMessage(), e); - // Return empty list rather than blowing up the page — - // the book picker will simply show nothing. - } - - return books; - } -} \ No newline at end of file diff --git a/src/main/java/org/zaine/app/service/CheckinService.java b/src/main/java/org/zaine/app/service/CheckinService.java deleted file mode 100755 index d48ff0d..0000000 --- a/src/main/java/org/zaine/app/service/CheckinService.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.zaine.app.service; - -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; -import org.zaine.app.dto.EveningCheckinRequest; -import org.zaine.app.dto.MorningCheckinRequest; -import org.zaine.app.dto.TodayStatusDTO; -import org.zaine.app.model.DailyCheckin; -import org.zaine.app.repositories.DailyCheckinRepository; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.OffsetDateTime; -import java.util.List; - -@Service -public class CheckinService { - - private final DailyCheckinRepository dailyCheckinRepository; - - public CheckinService(DailyCheckinRepository dailyCheckinRepository) { - this.dailyCheckinRepository = dailyCheckinRepository; - } - - public TodayStatusDTO getTodayStatus() { - return toStatus(getOrCreate(LocalDate.now())); - } - - public List getHistory(LocalDate from, LocalDate to) { - if (to.isBefore(from)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on or after from"); - } - return dailyCheckinRepository.findByDateBetweenOrderByDateDesc(from, to) - .stream() - .map(this::toStatus) - .toList(); - } - - public TodayStatusDTO saveMorning(MorningCheckinRequest request) { - LocalDate date = request.date() == null ? LocalDate.now() : request.date(); - validateSleepHours(request.sleepHours()); - - DailyCheckin checkin = getOrCreate(date); - checkin.setSleepHours(request.sleepHours()); - checkin.setMorningEnergyLevel(request.energyLevel()); - checkin.setMorningMoodLevel(request.mood()); - checkin.setFajr(request.fajr()); - checkin.setQuran(request.quran()); - checkin.setExercise(request.exercise()); - checkin.setMorningNote(blankToNull(request.note())); - checkin.setMorningCompletedAt(OffsetDateTime.now()); - - return toStatus(dailyCheckinRepository.save(checkin)); - } - - public TodayStatusDTO saveEvening(EveningCheckinRequest request) { - LocalDate date = request.date() == null ? LocalDate.now() : request.date(); - - DailyCheckin checkin = getOrCreate(date); - checkin.setEveningMoodLevel(request.mood()); - checkin.setStressLevel(request.stressLevel()); - checkin.setDuties(request.duties()); - checkin.setZikr(request.zikr()); - checkin.setSalah(request.salah()); - checkin.setReflection(blankToNull(request.reflection())); - checkin.setBestThingToday(blankToNull(request.bestThingToday())); - checkin.setEveningCompletedAt(OffsetDateTime.now()); - - return toStatus(dailyCheckinRepository.save(checkin)); - } - - private DailyCheckin getOrCreate(LocalDate date) { - return dailyCheckinRepository.findByDate(date).orElseGet(() -> { - DailyCheckin checkin = new DailyCheckin(); - checkin.setDate(date); - return checkin; - }); - } - - private TodayStatusDTO toStatus(DailyCheckin checkin) { - return new TodayStatusDTO( - checkin.getDate(), - checkin.getMorningCompletedAt() != null, - checkin.getEveningCompletedAt() != null, - checkin.getSleepHours(), - checkin.getMorningEnergyLevel(), - checkin.getMorningMoodLevel(), - checkin.getEveningMoodLevel(), - checkin.getStressLevel(), - checkin.getFajr(), - checkin.getQuran(), - checkin.getExercise(), - checkin.getDuties(), - checkin.getZikr(), - checkin.getSalah(), - checkin.getReflection(), - checkin.getMorningCompletedAt(), - checkin.getEveningCompletedAt()); - } - - private void validateSleepHours(BigDecimal sleepHours) { - if (sleepHours == null || sleepHours.compareTo(BigDecimal.ZERO) < 0 || sleepHours.compareTo(new BigDecimal("24")) > 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sleepHours must be between 0 and 24"); - } - } - - private String blankToNull(String value) { - return value == null || value.isBlank() ? null : value.trim(); - } -} diff --git a/src/main/java/org/zaine/app/service/CommentsService.java b/src/main/java/org/zaine/app/service/CommentsService.java index 021c09c..35179b3 100755 --- a/src/main/java/org/zaine/app/service/CommentsService.java +++ b/src/main/java/org/zaine/app/service/CommentsService.java @@ -1,15 +1,18 @@ package org.zaine.app.service; -import java.lang.System.Logger; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.zaine.app.model.Comments; import org.zaine.app.repositories.CommentsRepository; +import org.zaine.app.common.application.ApplicationException; import java.util.stream.*; +import java.time.Instant; @Service public class CommentsService { - private static final Logger logger = System.getLogger(CommentsService.class.getName()); + private static final Logger log = LoggerFactory.getLogger(CommentsService.class); private final CommentsRepository commentsRepository; @@ -22,7 +25,7 @@ public class CommentsService { } public List getAllCommentsBySlug(String page_slug) { - logger.log(System.Logger.Level.INFO, "Entering getAllCommentsBySlug with slug: " + page_slug); + log.debug("Finding comments for page slug {}", page_slug); List allComments = commentsRepository.findAll(); List filteredComments = allComments.stream() .filter(comment -> page_slug.equals(comment.getPageSlug())) @@ -31,28 +34,32 @@ public class CommentsService { } public Comments getCommentById(Integer id) { - logger.log(System.Logger.Level.INFO, "Entering getCommentById with id: " + id); if (id == null) { - logger.log(System.Logger.Level.WARNING, "Comment id is null. Operation aborted."); + log.warn("Comment lookup skipped because id was null"); return null; } - return commentsRepository.findById(id).get(); + return commentsRepository.findById(id) + .orElseThrow(() -> ApplicationException.notFound("Comment not found")); } - public void addComment(Comments comment) { - if (comment == null) { - logger.log(System.Logger.Level.WARNING, "Comment is null. Operation aborted."); - return; - } else { - logger.log(System.Logger.Level.INFO, "Adding comment with id: " + comment.getId()); - commentsRepository.save(comment); - logger.log(System.Logger.Level.INFO, "Added new comment with id: " + comment.getId()); - + public Comments addComment(String pageSlug, String author, String content, Integer parentId) { + if (content == null || content.trim().isEmpty()) { + log.warn("Comment creation skipped because content was empty"); + return null; } + Comments comment = new Comments(); + comment.setPageSlug(pageSlug); + comment.setAuthor(author); + comment.setContent(content); + comment.setParentId(parentId); + comment.setCreatedAt(Instant.now()); + Comments saved = commentsRepository.save(comment); + log.info("Created comment id={} for pageSlug={}", saved.getId(), saved.getPageSlug()); + return saved; } public List getCommentThread(Integer comment_id) { - logger.log(System.Logger.Level.INFO, "Entering getCommentThread with id: " + comment_id); + log.debug("Finding comment thread for id {}", comment_id); return commentsRepository.findCommentThread(comment_id); } } diff --git a/src/main/java/org/zaine/app/service/CompetenciesService.java b/src/main/java/org/zaine/app/service/CompetenciesService.java index 8984ec8..549a9b5 100755 --- a/src/main/java/org/zaine/app/service/CompetenciesService.java +++ b/src/main/java/org/zaine/app/service/CompetenciesService.java @@ -1,13 +1,15 @@ package org.zaine.app.service; -import java.lang.System.Logger; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.zaine.app.model.Competencies; import org.zaine.app.repositories.CompetenciesRepository; +import org.zaine.app.common.application.ApplicationException; @Service public class CompetenciesService { - private static final Logger logger = System.getLogger(CompetenciesService.class.getName()); + private static final Logger log = LoggerFactory.getLogger(CompetenciesService.class); private final CompetenciesRepository competenciesRepository; private static final List ALLOWED_STATES = List.of("completed", "manager_review", "in_progress", "not_started", "comments"); @@ -25,12 +27,12 @@ public class CompetenciesService { } public Competencies getCompetencyById(Integer id) { - logger.log(System.Logger.Level.INFO, "Entering getCompetencyById with id: " + id); if (id == null) { - logger.log(System.Logger.Level.WARNING, "Competency id is null. Operation aborted."); + log.warn("Competency lookup skipped because id was null"); return null; } - return competenciesRepository.findById(id).get(); + return competenciesRepository.findById(id) + .orElseThrow(() -> ApplicationException.notFound("Competency not found")); } public void updateCompetencyState(Integer id, String newState) { diff --git a/src/main/java/org/zaine/app/service/MotalahService.java b/src/main/java/org/zaine/app/service/MotalahService.java index 2cb397a..08f69da 100755 --- a/src/main/java/org/zaine/app/service/MotalahService.java +++ b/src/main/java/org/zaine/app/service/MotalahService.java @@ -1,6 +1,5 @@ package org.zaine.app.service; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.zaine.app.dto.MotalahSessionDTO; import org.zaine.app.model.MotalahSession; @@ -13,8 +12,11 @@ import java.util.List; @Service public class MotalahService { - @Autowired - private MotalahSessionRepository repo; + private final MotalahSessionRepository repo; + + public MotalahService(MotalahSessionRepository repo) { + this.repo = repo; + } /** All sessions, newest first. */ public List getAll() { @@ -45,4 +47,4 @@ public class MotalahService { public void delete(Long id) { repo.deleteById(id); } -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/service/NotesService.java b/src/main/java/org/zaine/app/service/NotesService.java deleted file mode 100755 index 40bd70e..0000000 --- a/src/main/java/org/zaine/app/service/NotesService.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.zaine.app.service; - -import java.util.List; -import java.util.Optional; - -import org.springframework.stereotype.Service; -import org.zaine.app.model.Notes; -import org.zaine.app.repositories.NotesRepository; - -@Service -public class NotesService { - private final NotesRepository notesRepository; - - public NotesService(NotesRepository notesRepository) { - this.notesRepository = notesRepository; - } - - public List getAllNotes() { - return Optional.ofNullable(notesRepository.findAll()) - .orElseThrow(() -> new RuntimeException("Failed to retrieve notes")); - } - - public Notes createNote(Notes note) { - return Optional.ofNullable(notesRepository.save(note)).orElseThrow(() -> new RuntimeException("Failed to create note")); - } - -} diff --git a/src/main/java/org/zaine/app/service/PeriodCompanionService.java b/src/main/java/org/zaine/app/service/PeriodCompanionService.java deleted file mode 100755 index e14671b..0000000 --- a/src/main/java/org/zaine/app/service/PeriodCompanionService.java +++ /dev/null @@ -1,571 +0,0 @@ -package org.zaine.app.service; - -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; -import org.zaine.app.dto.PeriodCompanionDTO.CalendarDayDTO; -import org.zaine.app.dto.PeriodCompanionDTO.CreateSymptomRequest; -import org.zaine.app.dto.PeriodCompanionDTO.CycleTrendDTO; -import org.zaine.app.dto.PeriodCompanionDTO.DailyLogDTO; -import org.zaine.app.dto.PeriodCompanionDTO.DailyLogRequest; -import org.zaine.app.dto.PeriodCompanionDTO.DashboardDTO; -import org.zaine.app.dto.PeriodCompanionDTO.FastingLogDTO; -import org.zaine.app.dto.PeriodCompanionDTO.FastingLogRequest; -import org.zaine.app.dto.PeriodCompanionDTO.InsightsDTO; -import org.zaine.app.dto.PeriodCompanionDTO.MoodTrendDTO; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleDTO; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleRequest; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodEntryRequest; -import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO; -import org.zaine.app.dto.PeriodCompanionDTO.ReflectionDTO; -import org.zaine.app.dto.PeriodCompanionDTO.ReflectionRequest; -import org.zaine.app.dto.PeriodCompanionDTO.SettingsDTO; -import org.zaine.app.dto.PeriodCompanionDTO.SettingsRequest; -import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO; -import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest; -import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO; -import org.zaine.app.dto.PeriodCompanionDTO.SymptomTrendDTO; -import org.zaine.app.dto.PeriodCompanionDTO.WellbeingDTO; -import org.zaine.app.model.PeriodCycle; -import org.zaine.app.model.PeriodDailyReflection; -import org.zaine.app.model.PeriodDailyLog; -import org.zaine.app.model.PeriodFastingLog; -import org.zaine.app.model.PeriodPredictionSnapshot; -import org.zaine.app.model.PeriodSettings; -import org.zaine.app.model.PeriodSupportMessage; -import org.zaine.app.model.PeriodSymptom; -import org.zaine.app.repositories.PeriodCycleRepository; -import org.zaine.app.repositories.PeriodDailyReflectionRepository; -import org.zaine.app.repositories.PeriodDailyLogRepository; -import org.zaine.app.repositories.PeriodFastingLogRepository; -import org.zaine.app.repositories.PeriodPredictionSnapshotRepository; -import org.zaine.app.repositories.PeriodSettingsRepository; -import org.zaine.app.repositories.PeriodSupportMessageRepository; -import org.zaine.app.repositories.PeriodSymptomRepository; -import org.zaine.app.service.PeriodPredictionService.PredictionEstimate; - -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Service -public class PeriodCompanionService { - private final PeriodCycleRepository cycleRepository; - private final PeriodDailyLogRepository dailyLogRepository; - private final PeriodDailyReflectionRepository dailyReflectionRepository; - private final PeriodFastingLogRepository fastingLogRepository; - private final PeriodSymptomRepository symptomRepository; - private final PeriodPredictionSnapshotRepository predictionRepository; - private final PeriodSettingsRepository settingsRepository; - private final PeriodSupportMessageRepository supportMessageRepository; - private final PeriodPredictionService predictionService; - - public PeriodCompanionService( - PeriodCycleRepository cycleRepository, - PeriodDailyLogRepository dailyLogRepository, - PeriodDailyReflectionRepository dailyReflectionRepository, - PeriodFastingLogRepository fastingLogRepository, - PeriodSymptomRepository symptomRepository, - PeriodPredictionSnapshotRepository predictionRepository, - PeriodSettingsRepository settingsRepository, - PeriodSupportMessageRepository supportMessageRepository, - PeriodPredictionService predictionService) { - this.cycleRepository = cycleRepository; - this.dailyLogRepository = dailyLogRepository; - this.dailyReflectionRepository = dailyReflectionRepository; - this.fastingLogRepository = fastingLogRepository; - this.symptomRepository = symptomRepository; - this.predictionRepository = predictionRepository; - this.settingsRepository = settingsRepository; - this.supportMessageRepository = supportMessageRepository; - this.predictionService = predictionService; - } - - public DashboardDTO getDashboard() { - LocalDate today = LocalDate.now(); - List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - PeriodSettings settings = getOrCreateSettings(); - PredictionDTO prediction = latestPrediction(cycles, today, settings); - DailyLogDTO todayLog = dailyLogRepository.findByDate(today).map(this::toDailyLogDto).orElse(null); - return new DashboardDTO( - predictionService.currentCycle(cycles, today, settings.getAverageCycleLength(), settings.getAveragePeriodLength()), - prediction, - todayLog, - toSettingsDto(settings), - contextualSupportMessages(prediction, today), - getSupportMessages()); - } - - public List getCalendar(LocalDate from, LocalDate to) { - validateRange(from, to); - List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - PeriodSettings settings = getOrCreateSettings(); - List projections = predictionService.project( - cycles, - LocalDate.now(), - from, - to, - settings.getAverageCycleLength(), - settings.getAveragePeriodLength()); - Map logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to) - .stream() - .collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity())); - - List days = new ArrayList<>(); - for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) { - PeriodDailyLog log = logs.get(date); - LocalDate currentDate = date; - boolean periodDay = cycles.stream().anyMatch(cycle -> isPeriodDay(cycle, currentDate)); - days.add(new CalendarDayDTO( - date, - periodDay, - projections.stream().anyMatch(projection -> isPredictedPeriodDay(projection, currentDate)), - projections.stream().anyMatch(projection -> isFertileWindowDay(projection, currentDate)), - projections.stream().anyMatch(projection -> currentDate.equals(projection.predictedOvulationDate())), - log == null ? List.of() : log.getSymptoms().stream().map(PeriodSymptom::getName).sorted().toList(), - log == null ? null : toDailyLogDto(log))); - } - return days; - } - - public List getDailyLogs(LocalDate from, LocalDate to) { - validateRange(from, to); - return dailyLogRepository.findByDateBetweenOrderByDateDesc(from, to).stream().map(this::toDailyLogDto).toList(); - } - - public DailyLogDTO getDailyLog(LocalDate date) { - return dailyLogRepository.findByDate(date).map(this::toDailyLogDto).orElse(null); - } - - @Transactional - public DailyLogDTO saveDailyLog(DailyLogRequest request) { - LocalDate date = request.date() == null ? LocalDate.now() : request.date(); - PeriodDailyLog log = dailyLogRepository.findByDate(date).orElseGet(() -> { - PeriodDailyLog newLog = new PeriodDailyLog(); - newLog.setDate(date); - return newLog; - }); - log.setFlowLevel(blankToNull(request.flowLevel())); - log.setMood(blankToNull(request.mood())); - log.setEnergyLevel(blankToNull(request.energyLevel())); - log.setNotes(blankToNull(request.notes())); - log.setSymptoms(resolveSymptoms(request.symptoms())); - return toDailyLogDto(dailyLogRepository.save(log)); - } - - public List getSymptoms() { - return symptomRepository.findAllByOrderByNameAsc().stream().map(this::toSymptomDto).toList(); - } - - public SymptomDTO createSymptom(CreateSymptomRequest request) { - return toSymptomDto(resolveSymptom(requiredText(request.name(), "name"))); - } - - @Transactional - public PeriodCycleDTO saveCycle(PeriodCycleRequest request) { - LocalDate startDate = requiredDate(request.startDate(), "startDate"); - LocalDate endDate = request.endDate(); - validateCycleDates(startDate, endDate); - PeriodCycle cycle = cycleRepository.findByStartDate(startDate).orElseGet(PeriodCycle::new); - cycle.setStartDate(startDate); - cycle.setEndDate(endDate); - setDerivedCycleValues(cycle); - PeriodCycle saved = cycleRepository.save(cycle); - updatePreviousCycleLength(saved); - return predictionService.toCycleDto(saved); - } - - public List getCycles() { - return cycleRepository.findAllByOrderByStartDateAsc().stream().map(predictionService::toCycleDto).toList(); - } - - @Transactional - public PeriodCycleDTO savePeriodEntry(PeriodEntryRequest request) { - LocalDate startDate = requiredDate(request.startDate(), "startDate"); - LocalDate endDate = request.endDate() == null ? startDate : request.endDate(); - validateCycleDates(startDate, endDate); - PeriodCycleDTO cycle = saveCycle(new PeriodCycleRequest(startDate, endDate)); - for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - LocalDate logDate = date; - PeriodDailyLog log = dailyLogRepository.findByDate(logDate).orElseGet(() -> { - PeriodDailyLog newLog = new PeriodDailyLog(); - newLog.setDate(logDate); - return newLog; - }); - log.setFlowLevel(blankToNull(request.flowLevel()) == null ? "Medium" : request.flowLevel().trim()); - dailyLogRepository.save(log); - } - return cycle; - } - - public PredictionDTO generatePrediction() { - List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - PeriodSettings settings = getOrCreateSettings(); - PredictionEstimate estimate = predictionService.estimate( - cycles, - LocalDate.now(), - settings.getAverageCycleLength(), - settings.getAveragePeriodLength()); - PeriodPredictionSnapshot snapshot = new PeriodPredictionSnapshot(); - snapshot.setPredictedPeriodDate(estimate.predictedPeriodDate()); - snapshot.setPredictedOvulationDate(estimate.predictedOvulationDate()); - snapshot.setFertileWindowStart(estimate.fertileWindowStart()); - snapshot.setFertileWindowEnd(estimate.fertileWindowEnd()); - return predictionService.toPredictionDto(predictionRepository.save(snapshot), estimate); - } - - public InsightsDTO getInsights() { - List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - List logs = dailyLogRepository.findByDateBetweenOrderByDateAsc( - LocalDate.now().minusMonths(12), - LocalDate.now().plusMonths(1)); - List cycleTrends = cycles.stream() - .map(cycle -> new CycleTrendDTO(cycle.getStartDate(), cycle.getCycleLength(), cycle.getPeriodLength())) - .toList(); - List symptomTrends = logs.stream() - .flatMap(log -> log.getSymptoms().stream()) - .collect(Collectors.groupingBy(PeriodSymptom::getName, Collectors.counting())) - .entrySet() - .stream() - .sorted(Map.Entry.comparingByValue().reversed()) - .map(entry -> new SymptomTrendDTO(entry.getKey(), entry.getValue())) - .toList(); - List moodTrends = logs.stream() - .filter(log -> log.getMood() != null) - .map(log -> new MoodTrendDTO(log.getDate(), log.getMood())) - .toList(); - return new InsightsDTO(cycleTrends, symptomTrends, moodTrends); - } - - public WellbeingDTO getWellbeing() { - LocalDate today = LocalDate.now(); - List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - PeriodSettings settings = getOrCreateSettings(); - String phase = predictionService.currentCycle( - cycles, - today, - settings.getAverageCycleLength(), - settings.getAveragePeriodLength()).phase(); - ReflectionDTO todayReflection = dailyReflectionRepository.findByDate(today) - .map(this::toReflectionDto) - .orElseGet(() -> new ReflectionDTO(null, today, defaultReflectionPrompt(phase), null, null, null)); - - return new WellbeingDTO( - phase, - bodyInsight(phase), - supportiveAction(phase), - spiritualReminder(phase), - fiqhNote(phase), - todayReflection, - dailyReflectionRepository.findTop14ByOrderByDateDesc().stream().map(this::toReflectionDto).toList(), - getFastingLogs(), - fastingLogRepository.countByStatus("MISSED"), - fastingLogRepository.countByStatus("MADE_UP")); - } - - public List getFastingLogs() { - return fastingLogRepository.findTop50ByOrderByFastDateDesc().stream().map(this::toFastingLogDto).toList(); - } - - public FastingLogDTO saveFastingLog(FastingLogRequest request) { - if (request == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fasting log payload is required"); - } - LocalDate fastDate = requiredDate(request.fastDate(), "fastDate"); - String status = requiredText(request.status(), "status").toUpperCase(Locale.ROOT); - if (!status.equals("MISSED") && !status.equals("MADE_UP")) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status must be MISSED or MADE_UP"); - } - PeriodFastingLog log = fastingLogRepository.findByFastDate(fastDate).orElseGet(PeriodFastingLog::new); - log.setFastDate(fastDate); - log.setStatus(status); - log.setNotes(blankToNull(request.notes())); - return toFastingLogDto(fastingLogRepository.save(log)); - } - - public ReflectionDTO saveReflection(ReflectionRequest request) { - if (request == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "reflection payload is required"); - } - LocalDate date = request.date() == null ? LocalDate.now() : request.date(); - String prompt = blankToNull(request.prompt()); - if (prompt == null) { - List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - PeriodSettings settings = getOrCreateSettings(); - String phase = predictionService.currentCycle( - cycles, - date, - settings.getAverageCycleLength(), - settings.getAveragePeriodLength()).phase(); - prompt = defaultReflectionPrompt(phase); - } - - PeriodDailyReflection reflection = dailyReflectionRepository.findByDate(date).orElseGet(PeriodDailyReflection::new); - reflection.setDate(date); - reflection.setPrompt(prompt); - reflection.setResponse(blankToNull(request.response())); - return toReflectionDto(dailyReflectionRepository.save(reflection)); - } - - public SettingsDTO getSettings() { - return toSettingsDto(getOrCreateSettings()); - } - - public SettingsDTO updateSettings(SettingsRequest request) { - if (request == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "settings payload is required"); - } - PeriodSettings settings = getOrCreateSettings(); - if (request.averageCycleLength() != null) { - validateIntegerRange(request.averageCycleLength(), 15, 60, "averageCycleLength"); - settings.setAverageCycleLength(request.averageCycleLength()); - } - if (request.averagePeriodLength() != null) { - validateIntegerRange(request.averagePeriodLength(), 1, 15, "averagePeriodLength"); - settings.setAveragePeriodLength(request.averagePeriodLength()); - } - return toSettingsDto(settingsRepository.save(settings)); - } - - public List getSupportMessages() { - return supportMessageRepository.findTop20ByOrderByCreatedAtDesc().stream().map(this::toSupportMessageDto).toList(); - } - - public SupportMessageDTO createSupportMessage(SupportMessageRequest request) { - PeriodSupportMessage message = new PeriodSupportMessage(); - message.setAuthorName(blankToNull(request.authorName())); - message.setMessage(requiredText(request.message(), "message")); - return toSupportMessageDto(supportMessageRepository.save(message)); - } - - private PredictionDTO latestPrediction(List cycles, LocalDate today, PeriodSettings settings) { - PredictionEstimate estimate = predictionService.estimate( - cycles, - today, - settings.getAverageCycleLength(), - settings.getAveragePeriodLength()); - return predictionRepository.findFirstByOrderByGeneratedAtDesc() - .map(snapshot -> predictionService.toPredictionDto(snapshot, estimate)) - .orElseGet(() -> generatePrediction()); - } - - private PeriodSettings getOrCreateSettings() { - return settingsRepository.findById(PeriodSettings.SINGLETON_ID).orElseGet(() -> settingsRepository.save(new PeriodSettings())); - } - - private void setDerivedCycleValues(PeriodCycle cycle) { - if (cycle.getEndDate() != null) { - cycle.setPeriodLength((int) ChronoUnit.DAYS.between(cycle.getStartDate(), cycle.getEndDate()) + 1); - } - } - - private void updatePreviousCycleLength(PeriodCycle cycle) { - cycleRepository.findFirstByStartDateLessThanOrderByStartDateDesc(cycle.getStartDate()).ifPresent(previous -> { - previous.setCycleLength((int) ChronoUnit.DAYS.between(previous.getStartDate(), cycle.getStartDate())); - cycleRepository.save(previous); - }); - } - - private boolean isPeriodDay(PeriodCycle cycle, LocalDate date) { - if (cycle.getEndDate() != null) { - return !date.isBefore(cycle.getStartDate()) && !date.isAfter(cycle.getEndDate()); - } - int periodLength = cycle.getPeriodLength() == null ? 5 : cycle.getPeriodLength(); - return !date.isBefore(cycle.getStartDate()) && date.isBefore(cycle.getStartDate().plusDays(periodLength)); - } - - private boolean isPredictedPeriodDay(PredictionEstimate prediction, LocalDate date) { - return !date.isBefore(prediction.predictedPeriodDate()) - && date.isBefore(prediction.predictedPeriodDate().plusDays(prediction.averagePeriodLength())); - } - - private boolean isFertileWindowDay(PredictionEstimate prediction, LocalDate date) { - return !date.isBefore(prediction.fertileWindowStart()) && !date.isAfter(prediction.fertileWindowEnd()); - } - - private List contextualSupportMessages(PredictionDTO prediction, LocalDate today) { - long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate()); - long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate()); - List messages = new ArrayList<>(); - if (daysUntilPeriod >= 0 && daysUntilPeriod <= 3) { - messages.add("Period may be close. Keep things gentle and flexible."); - } - if (daysUntilOvulation >= 0 && daysUntilOvulation <= 3) { - messages.add("Ovulation is approaching. Energy and mood may shift."); - } - if (!today.isBefore(prediction.fertileWindowStart()) && !today.isAfter(prediction.fertileWindowEnd())) { - messages.add("Fertile window is active based on recent cycle history."); - } - if (messages.isEmpty()) { - messages.add("A steady day to check in, notice patterns, and support each other."); - } - return messages; - } - - private LinkedHashSet resolveSymptoms(List names) { - if (names == null) { - return new LinkedHashSet<>(); - } - return names.stream() - .map(name -> name == null ? "" : name.trim()) - .filter(name -> !name.isBlank()) - .map(this::resolveSymptom) - .collect(Collectors.toCollection(LinkedHashSet::new)); - } - - private PeriodSymptom resolveSymptom(String name) { - String normalized = toDisplayName(name); - return symptomRepository.findByNameIgnoreCase(normalized).orElseGet(() -> { - PeriodSymptom symptom = new PeriodSymptom(); - symptom.setName(normalized); - return symptomRepository.save(symptom); - }); - } - - private DailyLogDTO toDailyLogDto(PeriodDailyLog log) { - return new DailyLogDTO( - log.getId(), - log.getDate(), - log.getFlowLevel(), - log.getMood(), - log.getEnergyLevel(), - log.getNotes(), - log.getSymptoms().stream().sorted(Comparator.comparing(PeriodSymptom::getName)).map(this::toSymptomDto).toList(), - log.getCreatedAt(), - log.getUpdatedAt()); - } - - private SymptomDTO toSymptomDto(PeriodSymptom symptom) { - return new SymptomDTO(symptom.getId(), symptom.getName()); - } - - private SupportMessageDTO toSupportMessageDto(PeriodSupportMessage message) { - return new SupportMessageDTO(message.getId(), message.getAuthorName(), message.getMessage(), message.getCreatedAt()); - } - - private FastingLogDTO toFastingLogDto(PeriodFastingLog log) { - return new FastingLogDTO(log.getId(), log.getFastDate(), log.getStatus(), log.getNotes(), log.getCreatedAt(), log.getUpdatedAt()); - } - - private ReflectionDTO toReflectionDto(PeriodDailyReflection reflection) { - return new ReflectionDTO( - reflection.getId(), - reflection.getDate(), - reflection.getPrompt(), - reflection.getResponse(), - reflection.getCreatedAt(), - reflection.getUpdatedAt()); - } - - private SettingsDTO toSettingsDto(PeriodSettings settings) { - return new SettingsDTO(settings.getAverageCycleLength(), settings.getAveragePeriodLength(), settings.getUpdatedAt()); - } - - private String bodyInsight(String phase) { - return switch (phase) { - case "Menstrual" -> "Energy can be lower during hayd. Rest, warmth, hydration, and lighter plans may feel better."; - case "Follicular" -> "Energy often rebuilds after bleeding. This can be a good time for planning, movement, and fresh routines."; - case "Ovulation" -> "Some people feel more social and alert around ovulation, while others notice cramps or tenderness."; - case "Luteal" -> "Mood, sleep, cravings, and focus can shift in the luteal phase. Gentler expectations can help."; - default -> "Track a few cycles and the guidance will become more specific to your rhythm."; - }; - } - - private String supportiveAction(String phase) { - return switch (phase) { - case "Menstrual" -> "Choose one kind thing for the body today: heat, a slow walk, extra water, or an early night."; - case "Follicular" -> "Pick one task that benefits future you while your energy is returning."; - case "Ovulation" -> "Use the clearer moments for connection, but keep space for rest if your body asks for it."; - case "Luteal" -> "Reduce friction where possible: prepare simple meals, lower the pace, and write down what feels heavy."; - default -> "Make one small note about what your body is telling you today."; - }; - } - - private String spiritualReminder(String phase) { - return switch (phase) { - case "Menstrual" -> "Even when salah and fasting pause, dhikr, dua, gratitude, and reflection remain open doors."; - case "Follicular" -> "As energy returns, renew intentions gently rather than trying to catch up all at once."; - case "Ovulation" -> "Use moments of ease for shukr and sincere dua."; - case "Luteal" -> "When emotions feel louder, small consistent remembrance can be grounding."; - default -> "Notice the body as an amanah and meet it with mercy."; - }; - } - - private String fiqhNote(String phase) { - if ("Menstrual".equals(phase)) { - return "Hayd-related rulings can vary by madhhab and situation. For spotting, istihadah, ghusl, or confusing bleeding, check a trusted scholar or teacher."; - } - return "For fasting make-ups, spotting, istihadah, and ghusl questions, use this as a reminder tool and confirm details with trusted fiqh guidance."; - } - - private String defaultReflectionPrompt(String phase) { - return switch (phase) { - case "Menstrual" -> "What would mercy toward your body look like today?"; - case "Follicular" -> "What intention do you want to rebuild gently this week?"; - case "Ovulation" -> "Where can you use today’s energy with gratitude and balance?"; - case "Luteal" -> "What can you simplify before your next period begins?"; - default -> "What did you notice in your body and heart today?"; - }; - } - - private void validateRange(LocalDate from, LocalDate to) { - requiredDate(from, "from"); - requiredDate(to, "to"); - if (to.isBefore(from)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on or after from"); - } - } - - private void validateCycleDates(LocalDate startDate, LocalDate endDate) { - if (endDate != null && endDate.isBefore(startDate)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "endDate must be on or after startDate"); - } - if (endDate != null && ChronoUnit.DAYS.between(startDate, endDate) > 14) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "period length must be 15 days or less"); - } - } - - private LocalDate requiredDate(LocalDate value, String field) { - if (value == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); - } - return value; - } - - private String requiredText(String value, String field) { - String text = blankToNull(value); - if (text == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); - } - if (text.length() > 500) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " must be 500 characters or fewer"); - } - return text; - } - - private void validateIntegerRange(Integer value, int min, int max, String field) { - if (value < min || value > max) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " must be between " + min + " and " + max); - } - } - - private String blankToNull(String value) { - return value == null || value.isBlank() ? null : value.trim(); - } - - private String toDisplayName(String value) { - String trimmed = requiredText(value, "name"); - if (trimmed.length() > 80) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name must be 80 characters or fewer"); - } - return trimmed.substring(0, 1).toUpperCase(Locale.ROOT) + trimmed.substring(1); - } -} diff --git a/src/main/java/org/zaine/app/service/PeriodPredictionService.java b/src/main/java/org/zaine/app/service/PeriodPredictionService.java deleted file mode 100755 index 7b374e5..0000000 --- a/src/main/java/org/zaine/app/service/PeriodPredictionService.java +++ /dev/null @@ -1,176 +0,0 @@ -package org.zaine.app.service; - -import org.springframework.stereotype.Service; -import org.zaine.app.dto.PeriodCompanionDTO.CurrentCycleDTO; -import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleDTO; -import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO; -import org.zaine.app.model.PeriodCycle; -import org.zaine.app.model.PeriodPredictionSnapshot; - -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; - -@Service -public class PeriodPredictionService { - public static final int DEFAULT_CYCLE_LENGTH = 28; - public static final int DEFAULT_PERIOD_LENGTH = 5; - - public PredictionEstimate estimate(List cycles, LocalDate today) { - return estimate(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH); - } - - public PredictionEstimate estimate(List cycles, LocalDate today, int fallbackCycleLength, int fallbackPeriodLength) { - int averageCycleLength = averageCycleLength(cycles, fallbackCycleLength); - int averagePeriodLength = averagePeriodLength(cycles, fallbackPeriodLength); - LocalDate anchor = cycles.stream() - .map(PeriodCycle::getStartDate) - .max(Comparator.naturalOrder()) - .orElse(today); - - LocalDate predictedPeriodDate = anchor.plusDays(averageCycleLength); - while (!predictedPeriodDate.isAfter(today)) { - predictedPeriodDate = predictedPeriodDate.plusDays(averageCycleLength); - } - - LocalDate predictedOvulationDate = predictedPeriodDate.minusDays(14); - return new PredictionEstimate( - predictedPeriodDate, - predictedOvulationDate, - predictedOvulationDate.minusDays(5), - predictedOvulationDate.plusDays(1), - averageCycleLength, - averagePeriodLength); - } - - public List project( - List cycles, - LocalDate today, - LocalDate from, - LocalDate to, - int fallbackCycleLength, - int fallbackPeriodLength) { - PredictionEstimate first = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength); - List projections = new ArrayList<>(); - LocalDate predictedPeriodDate = first.predictedPeriodDate(); - - while (!predictedPeriodDate.minusDays(19).isAfter(to)) { - LocalDate predictedOvulationDate = predictedPeriodDate.minusDays(14); - PredictionEstimate projection = new PredictionEstimate( - predictedPeriodDate, - predictedOvulationDate, - predictedOvulationDate.minusDays(5), - predictedOvulationDate.plusDays(1), - first.averageCycleLength(), - first.averagePeriodLength()); - - if (intersectsRange(projection, from, to)) { - projections.add(projection); - } - predictedPeriodDate = predictedPeriodDate.plusDays(first.averageCycleLength()); - } - - return projections; - } - - public CurrentCycleDTO currentCycle(List cycles, LocalDate today) { - return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH); - } - - public CurrentCycleDTO currentCycle(List cycles, LocalDate today, int fallbackCycleLength, int fallbackPeriodLength) { - PredictionEstimate estimate = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength); - Optional current = cycles.stream() - .filter(cycle -> !cycle.getStartDate().isAfter(today)) - .max(Comparator.comparing(PeriodCycle::getStartDate)); - - if (current.isEmpty()) { - return new CurrentCycleDTO(null, "Unknown", null, estimate.averageCycleLength(), estimate.averagePeriodLength()); - } - - PeriodCycle cycle = current.get(); - int day = (int) ChronoUnit.DAYS.between(cycle.getStartDate(), today) + 1; - int ovulationDay = Math.max(1, estimate.averageCycleLength() - 14); - int periodLength = cycle.getPeriodLength() != null ? cycle.getPeriodLength() : estimate.averagePeriodLength(); - - String phase; - if (day <= periodLength) { - phase = "Menstrual"; - } else if (Math.abs(day - ovulationDay) <= 1) { - phase = "Ovulation"; - } else if (day < ovulationDay) { - phase = "Follicular"; - } else { - phase = "Luteal"; - } - - return new CurrentCycleDTO(day, phase, toCycleDto(cycle), estimate.averageCycleLength(), estimate.averagePeriodLength()); - } - - public PredictionDTO toPredictionDto(PeriodPredictionSnapshot snapshot, PredictionEstimate estimate) { - return new PredictionDTO( - snapshot.getId(), - snapshot.getGeneratedAt(), - snapshot.getPredictedPeriodDate(), - snapshot.getPredictedOvulationDate(), - snapshot.getFertileWindowStart(), - snapshot.getFertileWindowEnd(), - estimate.averageCycleLength(), - estimate.averagePeriodLength()); - } - - public PeriodCycleDTO toCycleDto(PeriodCycle cycle) { - return new PeriodCycleDTO( - cycle.getId(), - cycle.getStartDate(), - cycle.getEndDate(), - cycle.getCycleLength(), - cycle.getPeriodLength(), - cycle.getCreatedAt(), - cycle.getUpdatedAt()); - } - - private int averageCycleLength(List cycles, int fallbackCycleLength) { - List values = cycles.stream() - .map(PeriodCycle::getCycleLength) - .filter(length -> length != null && length >= 15 && length <= 60) - .toList(); - return roundedAverage(values, fallbackCycleLength); - } - - private int averagePeriodLength(List cycles, int fallbackPeriodLength) { - List values = cycles.stream() - .map(PeriodCycle::getPeriodLength) - .filter(length -> length != null && length >= 1 && length <= 15) - .toList(); - return roundedAverage(values, fallbackPeriodLength); - } - - private int roundedAverage(List values, int fallback) { - if (values.isEmpty()) { - return fallback; - } - double average = values.stream().mapToInt(Integer::intValue).average().orElse(fallback); - return (int) Math.round(average); - } - - private boolean intersectsRange(PredictionEstimate projection, LocalDate from, LocalDate to) { - LocalDate periodEnd = projection.predictedPeriodDate().plusDays(projection.averagePeriodLength() - 1L); - return rangesOverlap(projection.predictedPeriodDate(), periodEnd, from, to) - || rangesOverlap(projection.fertileWindowStart(), projection.fertileWindowEnd(), from, to); - } - - private boolean rangesOverlap(LocalDate firstStart, LocalDate firstEnd, LocalDate secondStart, LocalDate secondEnd) { - return !firstStart.isAfter(secondEnd) && !secondStart.isAfter(firstEnd); - } - - public record PredictionEstimate( - LocalDate predictedPeriodDate, - LocalDate predictedOvulationDate, - LocalDate fertileWindowStart, - LocalDate fertileWindowEnd, - int averageCycleLength, - int averagePeriodLength) {} -} diff --git a/src/main/java/org/zaine/app/service/TimesheetService.java b/src/main/java/org/zaine/app/service/TimesheetService.java index 24498aa..e2aaf3f 100755 --- a/src/main/java/org/zaine/app/service/TimesheetService.java +++ b/src/main/java/org/zaine/app/service/TimesheetService.java @@ -7,9 +7,8 @@ import java.util.Map; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.stereotype.Service; +import org.zaine.app.common.application.ApplicationException; import org.zaine.app.model.TimesheetYear; import org.zaine.app.repositories.TimesheetYearRepository; import org.zaine.app.security.JwtUtil; @@ -64,20 +63,18 @@ public class TimesheetService { if (token != null && jwtUtil.isTokenValid(token)) { return; } - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing credentials"); + throw ApplicationException.unauthorized("Invalid or missing credentials"); } public void enforcePayloadSize(JsonNode body) { try { int size = objectMapper.writeValueAsBytes(body).length; if (size > maxPayloadBytes) { - throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, - "Timesheet payload exceeds " + maxPayloadBytes + " bytes" - ); + throw ApplicationException.payloadTooLarge( + "Timesheet payload exceeds " + maxPayloadBytes + " bytes"); } } catch (JsonProcessingException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid JSON"); + throw ApplicationException.badRequest("Invalid JSON"); } } @@ -121,7 +118,7 @@ public class TimesheetService { ObjectNode normalized = body.isObject() ? (ObjectNode) body.deepCopy() : objectMapper.createObjectNode(); normalized.put("year", year); if (!normalized.has("entries")) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload must include entries object"); + throw ApplicationException.badRequest("Payload must include entries object"); } validateYearPayload(year, normalized); saveRow(year, normalized); @@ -133,7 +130,7 @@ public class TimesheetService { enforcePayloadSize(body); JsonNode incoming = body.has("entries") ? body.get("entries") : body; if (!incoming.isObject()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Expected entries object"); + throw ApplicationException.badRequest("Expected entries object"); } ObjectNode result; @@ -242,16 +239,16 @@ public class TimesheetService { row.setSavedAt(java.time.Instant.now()); repository.save(row); } catch (JsonProcessingException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid JSON"); + throw ApplicationException.badRequest("Invalid JSON"); } } private void validateYearPayload(int year, JsonNode body) { if (!body.has("entries") || !body.get("entries").isObject()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload must include entries object"); + throw ApplicationException.badRequest("Payload must include entries object"); } if (body.has("year") && body.get("year").asInt() != year) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Year mismatch"); + throw ApplicationException.badRequest("Year mismatch"); } } @@ -259,7 +256,7 @@ public class TimesheetService { try { return objectMapper.readTree(payload); } catch (JsonProcessingException ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Corrupt timesheet payload"); + throw ApplicationException.failure("Corrupt timesheet payload"); } } } diff --git a/src/main/java/org/zaine/app/service/WirdService.java b/src/main/java/org/zaine/app/service/WirdService.java index 28d75b4..4315487 100755 --- a/src/main/java/org/zaine/app/service/WirdService.java +++ b/src/main/java/org/zaine/app/service/WirdService.java @@ -1,11 +1,11 @@ package org.zaine.app.service; -import org.springframework.beans.factory.annotation.Autowired; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.zaine.app.dto.WirdEntryDTO; import org.zaine.app.model.WirdEntry; import org.zaine.app.repositories.WirdEntryRepository; - import java.lang.System.Logger; import java.time.LocalDate; import java.util.List; @@ -14,10 +14,12 @@ import java.util.Set; @Service public class WirdService { - private static final Logger logger = System.getLogger(WirdService.class.getName()); - - @Autowired - private WirdEntryRepository repo; + private static final Logger log = LoggerFactory.getLogger(WirdService.class); + private final WirdEntryRepository repo; + + public WirdService(WirdEntryRepository repo) { + this.repo = repo; + } public List getAllEntries() { return repo.findAllByOrderByDateDescCreatedAtDesc(); @@ -46,7 +48,7 @@ public class WirdService { public void deleteEntry(Long id) { if (id == null) { - logger.log(System.Logger.Level.WARNING, "Entry id is null. Operation aborted."); + log.warn("Wird entry deletion skipped because id was null"); return; } repo.deleteById(id); diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100755 index 0000000..160780e --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,44 @@ +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver} +calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db + +server.port=9015 +spring.jpa.show-sql=${SPRING_JPA_SHOW_SQL:false} +spring.jpa.hibernate.ddl-auto=none +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true +spring.flyway.baseline-version=1 +timesheet.max-payload-bytes=5242880 + +jwt.secret=${JWT_SECRET} +jwt.expiration-ms=${JWT_EXPIRATION_MS:86400000} +auth.cookie.name=orgWebJwt +auth.cookie.secure=false +auth.cookie.max-age-seconds=86400 +org.auth.api-key=${ORG_BACKEND_API_KEY:} +timesheet.auth.required=${TIMESHEET_AUTH_REQUIRED:false} + +zone.build.dir=/home/zaine/master-folder/org-platform/org_web +zone.build.log=/home/zaine/master-folder/org-platform/org_web/org-web-build.log +zone.manifest.path=/home/zaine/master-folder/org-platform/zone/data/manifest.json +zone.authoring.url=http://127.0.0.1:8765 +zone.watcher.unit=watcher.service +zone.scripts.log.dir=/home/zaine/logs +zone.resources.storage.path=/ +emacs.run.dir=/home/zaine +emacs.run.log=/home/zaine/logs/emacs.log +combined.run.log=/home/zaine/logs/combined.log +adventure.resources.free.dir=/home/zaine +adventure.resources.free.log=/home/zaine/logs/adventure-resources.log +adventure.resources.free.command=sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af +guacamole.run.dir=/home/zaine +guacamole.container.name=guacamole +guacamole.start.log=/home/zaine/logs/guacamole-start.log +guacamole.stop.log=/home/zaine/logs/guacamole-stop.log +nostalgia.run.log=/home/zaine/logs/nostalgia-prod.log +play.rpg.save-dir=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves + +spring.web.resources.add-mappings=false diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100755 index 0000000..7b26272 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,44 @@ +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver} +calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db + +server.port=9010 +spring.jpa.show-sql=${SPRING_JPA_SHOW_SQL:false} +spring.jpa.hibernate.ddl-auto=none +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +# Phase 2 DB had tables before Flyway; baseline records V1 as applied without re-running DDL +spring.flyway.baseline-on-migrate=true +spring.flyway.baseline-version=1 +timesheet.max-payload-bytes=5242880 + +jwt.secret=${JWT_SECRET} +jwt.expiration-ms=${JWT_EXPIRATION_MS:86400000} +auth.cookie.name=orgWebJwt +auth.cookie.domain=.zainezq.com +auth.cookie.secure=true +auth.cookie.max-age-seconds=86400 +org.auth.api-key=${ORG_BACKEND_API_KEY:} +timesheet.auth.required=${TIMESHEET_AUTH_REQUIRED:false} + +zone.build.dir=/home/zaine/master-folder/org-platform/org_web +zone.build.log=/home/zaine/master-folder/org-platform/org_web/org-web-build.log +zone.manifest.path=/home/zaine/master-folder/org-platform/zone/data/manifest.json +zone.authoring.url=http://127.0.0.1:8765 +zone.watcher.unit=watcher.service +zone.scripts.log.dir=/home/zaine/logs +zone.resources.storage.path=/ +emacs.run.dir=/home/zaine +emacs.run.log=/home/zaine/logs/emacs.log +combined.run.log=/home/zaine/logs/combined.log +adventure.resources.free.dir=/home/zaine +adventure.resources.free.log=/home/zaine/logs/adventure-resources.log +adventure.resources.free.command=sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af +guacamole.run.dir=/home/zaine +guacamole.container.name=guacamole +guacamole.start.log=/home/zaine/logs/guacamole-start.log +guacamole.stop.log=/home/zaine/logs/guacamole-stop.log +nostalgia.run.log=/home/zaine/logs/nostalgia-prod.log +play.rpg.save-dir=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves diff --git a/src/test/java/org/zaine/app/ApplicationTest.java b/src/test/java/org/zaine/app/ApplicationTest.java deleted file mode 100755 index ce5deae..0000000 --- a/src/test/java/org/zaine/app/ApplicationTest.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.zaine.app; - - -public class ApplicationTest { - - - -} diff --git a/src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java b/src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java new file mode 100644 index 0000000..6b5bb4e --- /dev/null +++ b/src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java @@ -0,0 +1,24 @@ +package org.zaine.app.calibre.application.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.zaine.app.calibre.application.port.out.CalibreCatalogPort; +import org.zaine.app.calibre.domain.CalibreBook; + +@ExtendWith(MockitoExtension.class) +class CalibreApplicationServiceTest { + @Mock CalibreCatalogPort catalog; + + @Test + void returnsBooksFromTheExternalCatalogueBoundary() { + when(catalog.findAll()).thenReturn(List.of(new CalibreBook(1, "Book", null))); + var books = new CalibreApplicationService(catalog).getBooks(); + assertEquals("", books.get(0).authors()); + } +} diff --git a/src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java b/src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java new file mode 100644 index 0000000..5b340dd --- /dev/null +++ b/src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java @@ -0,0 +1,54 @@ +package org.zaine.app.controller; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.zaine.app.controller.zone.BuildController; +import org.zaine.app.service.BuildRunStateService; +import org.zaine.app.config.BuildProperties; + +class RetiredEndpointContractTest { + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.standaloneSetup( + new BuildController(new BuildRunStateService(), mock(BuildProperties.class))).build(); + } + + @Test + void periodCompanionAndCheckinRoutesAreNotExposed() throws Exception { + mvc.perform(get("/api/period-companion/dashboard")).andExpect(status().isNotFound()); + mvc.perform(get("/api/checkin/today-status")).andExpect(status().isNotFound()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.zaine.app.controller.PeriodCompanionController")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.zaine.app.controller.CheckinController")); + } + + @Test + void nostalgiaWriteRoutesAreGoneButStatusReadRemains() throws Exception { + mvc.perform(post("/api/nostalgia/production")).andExpect(status().isNotFound()); + mvc.perform(delete("/api/nostalgia/production")).andExpect(status().isNotFound()); + mvc.perform(get("/api/nostalgia/production/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.running").value(false)); + } + + @Test + void nostalgiaLogReadMappingRemains() throws Exception { + GetMapping mapping = BuildController.class + .getMethod("streamNostalgiaProductionLogs") + .getAnnotation(GetMapping.class); + assertArrayEquals(new String[] {"/nostalgia/production/logs"}, mapping.value()); + } +} diff --git a/src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java b/src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..1daa3b7 --- /dev/null +++ b/src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,30 @@ +package org.zaine.app.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.zaine.app.common.application.ApplicationException; + +class GlobalExceptionHandlerTest { + @Test + void mapsApplicationValidationErrorsWithoutLeakingImplementationDetails() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/example"); + + var response = new GlobalExceptionHandler() + .handleApplication(ApplicationException.badRequest("invalid input"), request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("invalid input", response.getBody().get("message")); + assertEquals("/api/example", response.getBody().get("path")); + } + + @Test + void preservesPayloadTooLargeStatus() { + MockHttpServletRequest request = new MockHttpServletRequest("PUT", "/api/timesheet/2026"); + var response = new GlobalExceptionHandler() + .handleApplication(ApplicationException.payloadTooLarge("too large"), request); + assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, response.getStatusCode()); + } +} diff --git a/src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java b/src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java new file mode 100644 index 0000000..17513ca --- /dev/null +++ b/src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java @@ -0,0 +1,48 @@ +package org.zaine.app.notes.application.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.zaine.app.notes.application.port.out.NoteRepositoryPort; +import org.zaine.app.notes.domain.Note; + +@ExtendWith(MockitoExtension.class) +class NotesApplicationServiceTest { + @Mock NoteRepositoryPort repository; + + @Test + void returnsNotesFromThePersistenceBoundary() { + when(repository.findAll()).thenReturn(List.of(Note.create("One", "A"), Note.create("Two", "B"))); + var service = new NotesApplicationService(repository); + + var notes = service.getAll(); + + assertEquals(List.of("One", "Two"), notes.stream().map(Note::content).toList()); + } + + @Test + void returnsAnEmptyListWhenNoNotesExist() { + when(repository.findAll()).thenReturn(List.of()); + assertTrue(new NotesApplicationService(repository).getAll().isEmpty()); + } + + @Test + void createsANoteThroughTheOutputPort() { + when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + var service = new NotesApplicationService(repository); + + Note created = service.create("New note", "Author"); + + assertEquals("New note", created.content()); + assertEquals("Author", created.authorName()); + verify(repository).save(any(Note.class)); + } +} diff --git a/src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java b/src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java new file mode 100644 index 0000000..c82c2e5 --- /dev/null +++ b/src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java @@ -0,0 +1,32 @@ +package org.zaine.app.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class BuildRunStateServiceTest { + @Test + void onlyOneCallerCanStartTheSameCommand() { + BuildRunStateService state = new BuildRunStateService(); + assertTrue(state.tryStart(BuildRunStateService.WEB)); + assertFalse(state.tryStart(BuildRunStateService.WEB)); + } + + @Test + void finishingACommandPublishesItsResultAndAllowsAnotherRun() { + BuildRunStateService state = new BuildRunStateService(); + state.tryStart(BuildRunStateService.WEB); + state.finish(BuildRunStateService.WEB, 7); + + assertFalse(state.webStatus().running()); + assertEquals(7, state.webStatus().lastExitCode()); + assertTrue(state.tryStart(BuildRunStateService.WEB)); + } + + @Test + void nostalgiaRemainsInTheReadOnlyLastRunSnapshot() { + assertTrue(new BuildRunStateService().lastRunsSnapshot().containsKey(BuildRunStateService.NOSTALGIA_PROD)); + } +} diff --git a/src/test/java/org/zaine/app/service/CommentsServiceTest.java b/src/test/java/org/zaine/app/service/CommentsServiceTest.java old mode 100755 new mode 100644 index ebecfe4..11cd545 --- a/src/test/java/org/zaine/app/service/CommentsServiceTest.java +++ b/src/test/java/org/zaine/app/service/CommentsServiceTest.java @@ -1,104 +1,81 @@ package org.zaine.app.service; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; -import java.util.Arrays; -import java.util.List; -import java.lang.System.Logger; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.List; +import java.util.Optional; 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.zaine.app.model.Comments; import org.zaine.app.repositories.CommentsRepository; -import org.mockito.junit.jupiter.MockitoExtension; +import org.zaine.app.common.application.ApplicationException; @ExtendWith(MockitoExtension.class) -public class CommentsServiceTest { - - private static final Logger logger = System.getLogger(CompetenciesService.class.getName()); - - @InjectMocks - private CommentsService commentsService; - - @Mock - private CommentsRepository commentsRepository; +class CommentsServiceTest { + @Mock CommentsRepository repository; @Test - public void testGetAllCommentsReturnsAllComments() { + void filtersCommentsByPageSlug() { + Comments included = comment("page-1", "Included"); + Comments excluded = comment("page-2", "Excluded"); + when(repository.findAll()).thenReturn(List.of(included, excluded)); - Comments comment1 = new Comments(); - comment1.setContent("Comment 1"); - Comments comment2 = new Comments(); - comment2.setContent("Comment 2"); - when(commentsRepository.findAll()).thenReturn(Arrays.asList(comment1, comment2)); - - List result = commentsService.getAllComments(); + var result = new CommentsService(repository).getAllCommentsBySlug("page-1"); - assertEquals(2, result.size()); - assertEquals("Comment 1", result.get(0).getContent()); - assertEquals("Comment 2", result.get(1).getContent()); - + assertEquals(List.of(included), result); } @Test - public void testGetAllCommentsBySlugReturnsFilteredComments() { - String slug = "page-1"; - Comments comment1 = new Comments(); - comment1.setContent("Comment 1"); - comment1.setPageSlug("page-1"); - Comments comment2 = new Comments(); - comment2.setContent("Comment 2"); - comment2.setPageSlug("page-2"); - when(commentsRepository.findAll()).thenReturn(Arrays.asList(comment1, comment2)); - - List result = commentsService.getAllCommentsBySlug(slug); - - assertEquals(1, result.size()); - assertEquals("Comment 1", result.get(0).getContent()); + void returnsCommentById() { + Comments comment = comment("page", "Content"); + when(repository.findById(1)).thenReturn(Optional.of(comment)); + assertEquals(comment, new CommentsService(repository).getCommentById(1)); } @Test - public void testGetCommentByIdReturnsComment() { - Comments comment = new Comments(); - Integer id = comment.getId(); - if (id == null) { - logger.log(System.Logger.Level.WARNING, "Comment id is null. Operation aborted."); - return; - } - when(commentsRepository.findById(id)).thenReturn(java.util.Optional.of(comment)); - - Comments result = commentsService.getCommentById(id); - assertSame(comment, result); + void reportsAMissingCommentAsNotFound() { + when(repository.findById(9)).thenReturn(Optional.empty()); + ApplicationException error = assertThrows(ApplicationException.class, + () -> new CommentsService(repository).getCommentById(9)); + assertEquals(ApplicationException.Kind.NOT_FOUND, error.kind()); } @Test - public void testAddCommentSavesComment() { - Comments comment = new Comments(); - comment.setContent("New Comment"); - when(commentsRepository.save(comment)).thenReturn(comment); - commentsService.addComment(comment); + void createsAValidComment() { + when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - verify(commentsRepository).save(comment); + Comments result = new CommentsService(repository).addComment("page", "Author", "Content", null); + assertEquals("Content", result.getContent()); + verify(repository).save(any(Comments.class)); } @Test - public void testGetCommentThreadReturnsThread() { - Integer commentId = 1; - Comments comment1 = new Comments(); - comment1.setContent("Reply 1"); - Comments comment2 = new Comments(); - comment2.setContent("Reply 2"); - when(commentsRepository.findCommentThread(commentId)).thenReturn(Arrays.asList(comment1, comment2)); - - List result = commentsService.getCommentThread(commentId); - assertEquals(2, result.size()); - assertEquals("Reply 1", result.get(0).getContent()); - assertEquals("Reply 2", result.get(1).getContent()); - + void ignoresAnEmptyCommentAsTheExistingApiContractRequires() { + Comments result = new CommentsService(repository).addComment("page", "Author", " ", null); + assertNull(result); + verify(repository, never()).save(any()); } + @Test + void returnsCommentThread() { + Comments reply = comment("page", "Reply"); + when(repository.findCommentThread(1)).thenReturn(List.of(reply)); + assertEquals(List.of(reply), new CommentsService(repository).getCommentThread(1)); + } + + private static Comments comment(String slug, String content) { + Comments comment = new Comments(); + comment.setPageSlug(slug); + comment.setContent(content); + return comment; + } } - diff --git a/src/test/java/org/zaine/app/service/CompetenciesServiceTest.java b/src/test/java/org/zaine/app/service/CompetenciesServiceTest.java old mode 100755 new mode 100644 index b2fc729..8625c42 --- a/src/test/java/org/zaine/app/service/CompetenciesServiceTest.java +++ b/src/test/java/org/zaine/app/service/CompetenciesServiceTest.java @@ -1,74 +1,59 @@ package org.zaine.app.service; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.zaine.app.repositories.CompetenciesRepository; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -import jakarta.validation.constraints.NotNull; - -import org.zaine.app.model.Competencies; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.lang.System.Logger; -import java.util.Arrays; +import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.zaine.app.model.Competencies; +import org.zaine.app.repositories.CompetenciesRepository; +import org.zaine.app.common.application.ApplicationException; -@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) -public class CompetenciesServiceTest { - - private static final Logger logger = System.getLogger(CompetenciesService.class.getName()); - - @InjectMocks - private CompetenciesService competenciesService; - - @Mock - private CompetenciesRepository competenciesRepository; +@ExtendWith(MockitoExtension.class) +class CompetenciesServiceTest { + @Mock CompetenciesRepository repository; @Test - public void testGetAllCompetencies() { - Competencies comp1 = new Competencies(); - comp1.setTitle("Competency 1"); - Competencies comp2 = new Competencies(); - comp2.setTitle("Competency 2"); - when(competenciesRepository.findAll()).thenReturn(Arrays.asList(comp1, comp2)); - - java.util.List result = competenciesService.getAllCompetencies(); - assertEquals(2, result.size()); - assertSame(comp1, result.get(0)); - assertSame(comp2, result.get(1)); - } - - @Test - public void testGetCompetencyByIdReturnsCompetency() { - Competencies comp = new Competencies(); - @NotNull Integer id = comp.getId(); - if (id == null) { - logger.log(System.Logger.Level.WARNING, "Competency id is null. Operation aborted."); - return; - } - when(competenciesRepository.findById(id)).thenReturn(Optional.of(comp)); - - - Competencies result = competenciesService.getCompetencyById(id); - assertSame(comp, result); - + void returnsAllCompetencies() { + Competencies competency = new Competencies(); + competency.setTitle("Competency"); + when(repository.findAll()).thenReturn(List.of(competency)); + assertEquals(List.of(competency), new CompetenciesService(repository).getAllCompetencies()); } @Test - public void testUpdateCompetencyStatusUpdatesCorrectly() { - Competencies comp = new Competencies(); - @NotNull Integer id = comp.getId(); - if (id == null) { - logger.log(System.Logger.Level.WARNING, "Competency id is null. Operation aborted."); - return; - } - when(competenciesRepository.findById(id)).thenReturn(Optional.of(comp)); + void returnsCompetencyById() { + Competencies competency = new Competencies(); + when(repository.findById(1)).thenReturn(Optional.of(competency)); + assertEquals(competency, new CompetenciesService(repository).getCompetencyById(1)); + } - competenciesService.updateCompetencyState(id, "completed"); - assertEquals("completed", comp.getState()); - verify(competenciesRepository).save(comp); - } + @Test + void reportsAMissingCompetencyAsNotFound() { + when(repository.findById(9)).thenReturn(Optional.empty()); + ApplicationException error = assertThrows(ApplicationException.class, + () -> new CompetenciesService(repository).getCompetencyById(9)); + assertEquals(ApplicationException.Kind.NOT_FOUND, error.kind()); + } + + @Test + void updatesAnAllowedState() { + Competencies competency = new Competencies(); + when(repository.findById(1)).thenReturn(Optional.of(competency)); + new CompetenciesService(repository).updateCompetencyState(1, "completed"); + assertEquals("completed", competency.getState()); + verify(repository).save(competency); + } + + @Test + void rejectsAnUnknownState() { + assertThrows(IllegalArgumentException.class, + () -> new CompetenciesService(repository).updateCompetencyState(1, "unknown")); + } } diff --git a/src/test/java/org/zaine/app/service/NotesServiceTest.java b/src/test/java/org/zaine/app/service/NotesServiceTest.java deleted file mode 100755 index 85ec266..0000000 --- a/src/test/java/org/zaine/app/service/NotesServiceTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.zaine.app.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.zaine.app.model.Notes; -import org.zaine.app.repositories.NotesRepository; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -public class NotesServiceTest { - - @InjectMocks - private NotesService notesService; - - @Mock - private NotesRepository notesRepository; - - - - @Test - public void testGetAllNotesReturnsAllNotes() { - - Notes note1 = new Notes(); - note1.setContent("Note 1"); - Notes note2 = new Notes(); - note2.setContent("Note 2"); - when(notesRepository.findAll()).thenReturn(Arrays.asList(note1, note2)); - - List result = notesService.getAllNotes(); - - assertEquals(2, result.size()); - assertEquals("Note 1", result.get(0).getContent()); - assertEquals("Note 2", result.get(1).getContent()); - - } - - @Test - public void testGetAllNotesReturnsEmptyListWhenNoNotes() { - when(notesRepository.findAll()).thenReturn(Arrays.asList()); - - List result = notesService.getAllNotes(); - - assertTrue(result.isEmpty()); - - } - - @Test - public void testCreateNoteSavesAndReturnsNote() { - Notes note = new Notes(); - note.setContent("New Note"); - note.setAuthorName("Author"); - - when(notesRepository.save(note)).thenReturn(note); - - Notes result = notesService.createNote(note); - - assertEquals("New Note", result.getContent()); - } - -} diff --git a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java deleted file mode 100755 index 5a4413a..0000000 --- a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.zaine.app.service; - -import org.junit.jupiter.api.Test; -import org.zaine.app.dto.PeriodCompanionDTO.CurrentCycleDTO; -import org.zaine.app.model.PeriodCycle; -import org.zaine.app.service.PeriodPredictionService.PredictionEstimate; - -import java.time.LocalDate; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -public class PeriodPredictionServiceTest { - private final PeriodPredictionService service = new PeriodPredictionService(); - - @Test - public void estimateUsesDefaultLengthsWhenNoHistoryExists() { - LocalDate today = LocalDate.of(2026, 6, 22); - - PredictionEstimate estimate = service.estimate(List.of(), today); - - assertEquals(LocalDate.of(2026, 7, 20), estimate.predictedPeriodDate()); - assertEquals(LocalDate.of(2026, 7, 6), estimate.predictedOvulationDate()); - assertEquals(LocalDate.of(2026, 7, 1), estimate.fertileWindowStart()); - assertEquals(LocalDate.of(2026, 7, 7), estimate.fertileWindowEnd()); - assertEquals(28, estimate.averageCycleLength()); - assertEquals(5, estimate.averagePeriodLength()); - } - - @Test - public void estimateUsesConfiguredFallbackLengthsWhenNoHistoryExists() { - LocalDate today = LocalDate.of(2026, 6, 22); - - PredictionEstimate estimate = service.estimate(List.of(), today, 32, 6); - - assertEquals(LocalDate.of(2026, 7, 24), estimate.predictedPeriodDate()); - assertEquals(32, estimate.averageCycleLength()); - assertEquals(6, estimate.averagePeriodLength()); - } - - @Test - public void estimateUsesHistoricalAveragesForFuturePrediction() { - LocalDate today = LocalDate.of(2026, 6, 22); - PeriodCycle may = cycle(LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 5), 30, 5); - PeriodCycle june = cycle(LocalDate.of(2026, 5, 31), LocalDate.of(2026, 6, 4), 28, 5); - - PredictionEstimate estimate = service.estimate(List.of(may, june), today); - - assertEquals(29, estimate.averageCycleLength()); - assertEquals(LocalDate.of(2026, 6, 29), estimate.predictedPeriodDate()); - assertEquals(LocalDate.of(2026, 6, 15), estimate.predictedOvulationDate()); - } - - @Test - public void projectReturnsRepeatedPredictionsAcrossLongDateRange() { - LocalDate today = LocalDate.of(2026, 6, 22); - - List projections = service.project( - List.of(), - today, - LocalDate.of(2026, 7, 1), - LocalDate.of(2026, 9, 30), - 28, - 5); - - assertEquals(4, projections.size()); - assertEquals(LocalDate.of(2026, 7, 20), projections.get(0).predictedPeriodDate()); - assertEquals(LocalDate.of(2026, 8, 17), projections.get(1).predictedPeriodDate()); - assertEquals(LocalDate.of(2026, 9, 14), projections.get(2).predictedPeriodDate()); - assertEquals(LocalDate.of(2026, 10, 12), projections.get(3).predictedPeriodDate()); - } - - @Test - public void currentCycleReturnsMenstrualPhaseDuringPeriodLength() { - PeriodCycle cycle = cycle(LocalDate.of(2026, 6, 20), LocalDate.of(2026, 6, 24), 28, 5); - - CurrentCycleDTO current = service.currentCycle(List.of(cycle), LocalDate.of(2026, 6, 22)); - - assertEquals(3, current.day()); - assertEquals("Menstrual", current.phase()); - } - - @Test - public void currentCycleReturnsUnknownWithoutCycleHistory() { - CurrentCycleDTO current = service.currentCycle(List.of(), LocalDate.of(2026, 6, 22)); - - assertNull(current.day()); - assertEquals("Unknown", current.phase()); - } - - private PeriodCycle cycle(LocalDate startDate, LocalDate endDate, Integer cycleLength, Integer periodLength) { - PeriodCycle cycle = new PeriodCycle(); - cycle.setStartDate(startDate); - cycle.setEndDate(endDate); - cycle.setCycleLength(cycleLength); - cycle.setPeriodLength(periodLength); - return cycle; - } -} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass