Refactor org backend and remove obsolete code
All checks were successful
Build Org Backend / build (push) Successful in 15s

This commit is contained in:
2026-07-21 13:38:25 +01:00
parent 016b9dcd81
commit cdbdcc5832
84 changed files with 1026 additions and 2835 deletions

4
.gitignore vendored
View File

@@ -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
.env

View File

@@ -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.

View File

@@ -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

View File

@@ -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<BookResponse> 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());
}
}
}

View File

@@ -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<CalibreBook> findAll() {
List<CalibreBook> 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;
}
}

View File

@@ -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<CalibreBook> getBooks();
}

View File

@@ -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<CalibreBook> findAll();
}

View File

@@ -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<CalibreBook> getBooks() {
return catalog.findAll();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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<CalibreBookDTO> getBooks() {
return calibreService.getAllBooks();
}
}

View File

@@ -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<TodayStatusDTO> 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<TodayStatusDTO> submitMorning(@RequestBody MorningCheckinRequest request) {
return ResponseEntity.ok(checkinService.saveMorning(request));
}
@Operation(summary = "Submit evening check-in")
@PostMapping("/evening")
public ResponseEntity<TodayStatusDTO> submitEvening(@RequestBody EveningCheckinRequest request) {
return ResponseEntity.ok(checkinService.saveEvening(request));
}
}

View File

@@ -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<Comments> getAllCommentsBySlug(@PathVariable String page_slug) {
List<Comments> allComments = commentsService.getAllCommentsBySlug(page_slug);
return allComments;
public List<CommentResponse> 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<Comments> getAllComments() {
return commentsService.getAllComments();
public List<CommentResponse> 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<Comments> getCommentThread(@PathVariable Integer comment_id) {
return commentsService.getCommentThread(comment_id);
public List<CommentResponse> 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());
}
}
}

View File

@@ -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<Competencies> getAllCompetencies(
public List<CompetencyResponse> 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());
}
}
}

View File

@@ -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<Notes> 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);
}
}

View File

@@ -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<CalendarDayDTO> 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<DailyLogDTO> 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<DailyLogDTO> saveDailyLog(@RequestBody DailyLogRequest request) {
return ResponseEntity.ok(periodCompanionService.saveDailyLog(request));
}
@Operation(summary = "Get symptoms")
@GetMapping("/symptoms")
public List<SymptomDTO> getSymptoms() {
return periodCompanionService.getSymptoms();
}
@Operation(summary = "Create a symptom")
@PostMapping("/symptoms")
public ResponseEntity<SymptomDTO> createSymptom(@RequestBody CreateSymptomRequest request) {
return ResponseEntity.ok(periodCompanionService.createSymptom(request));
}
@Operation(summary = "Get tracked cycles")
@GetMapping("/cycles")
public List<PeriodCycleDTO> getCycles() {
return periodCompanionService.getCycles();
}
@Operation(summary = "Create or update a cycle")
@PostMapping("/cycles")
public ResponseEntity<PeriodCycleDTO> 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<PeriodCycleDTO> savePeriodEntry(@RequestBody PeriodEntryRequest request) {
return ResponseEntity.ok(periodCompanionService.savePeriodEntry(request));
}
@Operation(summary = "Generate and store a prediction snapshot")
@PostMapping("/predictions/generate")
public ResponseEntity<PredictionDTO> 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<FastingLogDTO> getFastingLogs() {
return periodCompanionService.getFastingLogs();
}
@Operation(summary = "Create or update a fasting make-up log")
@PostMapping("/fasting-logs")
public ResponseEntity<FastingLogDTO> saveFastingLog(@RequestBody FastingLogRequest request) {
return ResponseEntity.ok(periodCompanionService.saveFastingLog(request));
}
@Operation(summary = "Create or update a daily reflection")
@PostMapping("/reflections")
public ResponseEntity<ReflectionDTO> 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<SettingsDTO> updateSettings(@RequestBody SettingsRequest request) {
return ResponseEntity.ok(periodCompanionService.updateSettings(request));
}
@Operation(summary = "Get recent support messages")
@GetMapping("/support-messages")
public List<SupportMessageDTO> getSupportMessages() {
return periodCompanionService.getSupportMessages();
}
@Operation(summary = "Create a support message")
@PostMapping("/support-messages")
public ResponseEntity<SupportMessageDTO> createSupportMessage(@RequestBody SupportMessageRequest request) {
return ResponseEntity.ok(periodCompanionService.createSupportMessage(request));
}
}

View File

@@ -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<WirdEntry> getAllEntries() {
logger.log(Logger.Level.INFO, "Fetching all wird entries");
return wirdService.getAllEntries();
public List<WirdEntryResponse> 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<WirdEntry> getTodayEntries() {
return wirdService.getTodayEntries();
public List<WirdEntryResponse> 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<WirdEntry> getEntriesInRange(
public List<WirdEntryResponse> 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<WirdEntry> getEntriesByType(
public List<WirdEntryResponse> 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<WirdEntry> createEntry(@RequestBody WirdEntryDTO dto) {
logger.log(Logger.Level.INFO, "Creating wird entry: {0} on {1}", dto.getWirdType(), dto.getDate());
public ResponseEntity<WirdEntryResponse> 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<Void> 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<WirdEntry> getNaflToday() {
return wirdService.getNaflForDate(LocalDate.now());
public List<WirdEntryResponse> getNaflToday() {
return toWirdResponses(wirdService.getNaflForDate(LocalDate.now()));
}
/**
@@ -116,8 +118,8 @@ public class WirdController {
* All khatm completions, newest first.
*/
@GetMapping("/entries/khatm")
public List<WirdEntry> getKhatmEntries() {
return wirdService.getKhatmEntries();
public List<WirdEntryResponse> getKhatmEntries() {
return toWirdResponses(wirdService.getKhatmEntries());
}
/**
@@ -125,9 +127,9 @@ public class WirdController {
* All study sessions, newest first.
*/
@GetMapping("/motalah")
public List<MotalahSession> getAll() {
logger.log(Logger.Level.INFO, "Fetching all motalah sessions");
return motalahService.getAll();
public List<MotalahResponse> 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<MotalahSession> getToday() {
return motalahService.getToday();
public List<MotalahResponse> 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<MotalahSession> getInRange(
public List<MotalahResponse> 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<MotalahSession> create(@RequestBody MotalahSessionDTO dto) {
logger.log(Logger.Level.INFO, "Creating motalah session: {0} min on {1}",
dto.getDurationMinutes(), dto.getDate());
public ResponseEntity<MotalahResponse> 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<Void> 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<WirdEntryResponse> toWirdResponses(List<WirdEntry> entries) {
return entries.stream().map(WirdEntryResponse::from).toList();
}
private static List<MotalahResponse> toMotalahResponses(List<MotalahSession> 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<Integer> 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());
}
}
}

View File

@@ -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<Process> resourceProcess = new AtomicReference<>();
private final AtomicReference<Process> guacamoleStartProcess = new AtomicReference<>();
private final AtomicReference<Process> guacamoleStopProcess = new AtomicReference<>();
private final AtomicReference<Process> 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<String> runNostalgiaProduction() {
return startCommand(
BuildRunStateService.NOSTALGIA_PROD,
"nostalgia production",
nostalgiaRunDirectory,
nostalgiaRunLogFile,
List.of("docker", "compose", "up", "-d", "--build"),
nostalgiaProcess
);
}
@DeleteMapping("/nostalgia/production")
public ResponseEntity<String> cancelNostalgiaProduction() {
return killProcess(
nostalgiaProcess,
BuildRunStateService.NOSTALGIA_PROD,
"nostalgia production"
);
}
@GetMapping("/nostalgia/production/status")
public ResponseEntity<BuildStatus> getNostalgiaProductionStatus() {
var s = buildRunState.status(BuildRunStateService.NOSTALGIA_PROD);

View File

@@ -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());
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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<SymptomDTO> symptoms,
OffsetDateTime createdAt,
OffsetDateTime updatedAt) {}
public record DailyLogRequest(
LocalDate date,
String flowLevel,
String mood,
String energyLevel,
String notes,
List<String> 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<String> supportMessages,
List<SupportMessageDTO> recentPartnerMessages) {}
public record CalendarDayDTO(
LocalDate date,
boolean periodDay,
boolean predictedPeriodDay,
boolean fertileWindow,
boolean ovulationDay,
List<String> 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<CycleTrendDTO> cycleTrends,
List<SymptomTrendDTO> symptomTrends,
List<MoodTrendDTO> 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<ReflectionDTO> recentReflections,
List<FastingLogDTO> fastingLogs,
long missedFastCount,
long madeUpFastCount) {}
}

View File

@@ -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) {
}

View File

@@ -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<Map<String, String>> handleNotFound(HttpServletRequest request) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
@@ -21,24 +25,30 @@ public class GlobalExceptionHandler {
);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, String>> handleResponseStatus(
ResponseStatusException ex,
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<Map<String, String>> 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<Map<String, String>> 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",

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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<PeriodSymptom> 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<PeriodSymptom> getSymptoms() { return symptoms; }
public void setSymptoms(Set<PeriodSymptom> symptoms) { this.symptoms = symptoms; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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<NoteResponse> 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());
}
}
}

View File

@@ -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; }
}

View File

@@ -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<Note> 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());
}
}

View File

@@ -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<NoteJpaEntity, UUID> {}

View File

@@ -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<Note> getAll();
Note create(String content, String authorName);
}

View File

@@ -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<Note> findAll();
Note save(Note note);
}

View File

@@ -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<Note> getAll() {
return repository.findAll();
}
@Override
public Note create(String content, String authorName) {
return repository.save(Note.create(content, authorName));
}
}

View File

@@ -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);
}
}

View File

@@ -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<DailyCheckin, Long> {
Optional<DailyCheckin> findByDate(LocalDate date);
List<DailyCheckin> findByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to);
}

View File

@@ -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<Notes, UUID> {
}

View File

@@ -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<PeriodCycle, Long> {
Optional<PeriodCycle> findByStartDate(LocalDate startDate);
Optional<PeriodCycle> findFirstByOrderByStartDateDesc();
Optional<PeriodCycle> findFirstByStartDateLessThanOrderByStartDateDesc(LocalDate startDate);
List<PeriodCycle> findByStartDateBetweenOrderByStartDateAsc(LocalDate from, LocalDate to);
List<PeriodCycle> findTop12ByEndDateIsNotNullOrderByStartDateDesc();
List<PeriodCycle> findAllByOrderByStartDateAsc();
}

View File

@@ -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<PeriodDailyLog, Long> {
Optional<PeriodDailyLog> findByDate(LocalDate date);
List<PeriodDailyLog> findByDateBetweenOrderByDateAsc(LocalDate from, LocalDate to);
List<PeriodDailyLog> findByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to);
}

View File

@@ -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<PeriodDailyReflection, Long> {
Optional<PeriodDailyReflection> findByDate(LocalDate date);
List<PeriodDailyReflection> findTop14ByOrderByDateDesc();
}

View File

@@ -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<PeriodFastingLog, Long> {
Optional<PeriodFastingLog> findByFastDate(LocalDate fastDate);
List<PeriodFastingLog> findTop50ByOrderByFastDateDesc();
long countByStatus(String status);
}

View File

@@ -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<PeriodPredictionSnapshot, Long> {
Optional<PeriodPredictionSnapshot> findFirstByOrderByGeneratedAtDesc();
}

View File

@@ -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<PeriodSettings, Short> {
}

View File

@@ -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<PeriodSupportMessage, Long> {
List<PeriodSupportMessage> findTop20ByOrderByCreatedAtDesc();
}

View File

@@ -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<PeriodSymptom, Long> {
Optional<PeriodSymptom> findByNameIgnoreCase(String name);
List<PeriodSymptom> findAllByOrderByNameAsc();
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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 {
}

View File

@@ -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();
}
}
}

View File

@@ -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<CalendarEventDTO> 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();

View File

@@ -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();
}

View File

@@ -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<CalibreBookDTO> getAllBooks() {
List<CalibreBookDTO> 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;
}
}

View File

@@ -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<TodayStatusDTO> 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();
}
}

View File

@@ -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<Comments> 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<Comments> allComments = commentsRepository.findAll();
List<Comments> 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<Comments> 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);
}
}

View File

@@ -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<String> 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) {

View File

@@ -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<MotalahSession> getAll() {
@@ -45,4 +47,4 @@ public class MotalahService {
public void delete(Long id) {
repo.deleteById(id);
}
}
}

View File

@@ -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<Notes> 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"));
}
}

View File

@@ -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<PeriodCycle> 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<CalendarDayDTO> getCalendar(LocalDate from, LocalDate to) {
validateRange(from, to);
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
PeriodSettings settings = getOrCreateSettings();
List<PredictionEstimate> projections = predictionService.project(
cycles,
LocalDate.now(),
from,
to,
settings.getAverageCycleLength(),
settings.getAveragePeriodLength());
Map<LocalDate, PeriodDailyLog> logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to)
.stream()
.collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity()));
List<CalendarDayDTO> 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<DailyLogDTO> 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<SymptomDTO> 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<PeriodCycleDTO> 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<PeriodCycle> 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<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
List<PeriodDailyLog> logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(
LocalDate.now().minusMonths(12),
LocalDate.now().plusMonths(1));
List<CycleTrendDTO> cycleTrends = cycles.stream()
.map(cycle -> new CycleTrendDTO(cycle.getStartDate(), cycle.getCycleLength(), cycle.getPeriodLength()))
.toList();
List<SymptomTrendDTO> symptomTrends = logs.stream()
.flatMap(log -> log.getSymptoms().stream())
.collect(Collectors.groupingBy(PeriodSymptom::getName, Collectors.counting()))
.entrySet()
.stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.map(entry -> new SymptomTrendDTO(entry.getKey(), entry.getValue()))
.toList();
List<MoodTrendDTO> 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<PeriodCycle> 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<FastingLogDTO> 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<PeriodCycle> 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<SupportMessageDTO> 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<PeriodCycle> 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<String> contextualSupportMessages(PredictionDTO prediction, LocalDate today) {
long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate());
long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate());
List<String> 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<PeriodSymptom> resolveSymptoms(List<String> 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 todays 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);
}
}

View File

@@ -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<PeriodCycle> cycles, LocalDate today) {
return estimate(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH);
}
public PredictionEstimate estimate(List<PeriodCycle> 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<PredictionEstimate> project(
List<PeriodCycle> cycles,
LocalDate today,
LocalDate from,
LocalDate to,
int fallbackCycleLength,
int fallbackPeriodLength) {
PredictionEstimate first = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength);
List<PredictionEstimate> 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<PeriodCycle> cycles, LocalDate today) {
return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH);
}
public CurrentCycleDTO currentCycle(List<PeriodCycle> cycles, LocalDate today, int fallbackCycleLength, int fallbackPeriodLength) {
PredictionEstimate estimate = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength);
Optional<PeriodCycle> 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<PeriodCycle> cycles, int fallbackCycleLength) {
List<Integer> values = cycles.stream()
.map(PeriodCycle::getCycleLength)
.filter(length -> length != null && length >= 15 && length <= 60)
.toList();
return roundedAverage(values, fallbackCycleLength);
}
private int averagePeriodLength(List<PeriodCycle> cycles, int fallbackPeriodLength) {
List<Integer> values = cycles.stream()
.map(PeriodCycle::getPeriodLength)
.filter(length -> length != null && length >= 1 && length <= 15)
.toList();
return roundedAverage(values, fallbackPeriodLength);
}
private int roundedAverage(List<Integer> 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) {}
}

View File

@@ -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");
}
}
}

View File

@@ -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<WirdEntry> 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);

View File

@@ -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

View File

@@ -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

View File

@@ -1,8 +0,0 @@
package org.zaine.app;
public class ApplicationTest {
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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<Comments> 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<Comments> 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<Comments> 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;
}
}

View File

@@ -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<Competencies> 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"));
}
}

View File

@@ -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<Notes> 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<Notes> 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());
}
}

View File

@@ -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<PredictionEstimate> 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;
}
}

View File

@@ -0,0 +1 @@
mock-maker-subclass