From 40647c8d6f4066e35183b0b0787720553a828e22 Mon Sep 17 00:00:00 2001 From: zhngharry Date: Wed, 15 Jul 2026 15:00:50 +0200 Subject: [PATCH] refactor(examlense): reorganize backend into package-by-feature slices Converge the backend on package-by-feature vertical slices. Dissolve the layer-based api/ and persistence/ packages: each domain (exam, section, task, grading, admin) now owns its controller + service + entity + repository + DTOs, alongside the existing parse/solve/lgh slices. Keep cross-cutting technical modules shared (ai, storage, sse, security, config, error, prompts) and add a shared/ package for Access, Patch, and DefaultUser. Split the monolithic Dtos.java into per-slice DTO holders and the cross-domain CrudService into ExamService/SectionService/TaskService. Pure package reshuffle: no endpoint paths or DB schema change. Relocate tests beside their subjects and update the backend README project layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/examlense/backend/README.md | 37 +++-- .../app/{api => admin}/AdminController.java | 14 +- .../main/java/app/admin/SurveyModelDto.java | 21 +++ .../backend/src/main/java/app/api/Dtos.java | 155 ------------------ .../main/java/app/config/SecurityConfig.java | 2 +- .../{persistence/entity => exam}/Exam.java | 2 +- .../app/{api => exam}/ExamController.java | 36 ++-- .../src/main/java/app/exam/ExamDtos.java | 50 ++++++ .../{api => exam}/ExamProgressService.java | 14 +- .../repository => exam}/ExamRepository.java | 3 +- .../ExamService.java} | 124 ++------------ .../src/main/java/app/grading/GradeDto.java | 16 ++ .../entity => grading}/TaskGrade.java | 2 +- .../{api => grading}/TaskGradeController.java | 19 +-- .../TaskGradeRepository.java | 3 +- .../src/main/java/app/lgh/LghController.java | 8 +- .../app/lgh/TaskGoalGenerationService.java | 18 +- .../main/java/app/parse/ParseExamService.java | 6 +- .../java/app/parse/ParseInputBuilder.java | 2 +- .../entity => parse}/ParseMetric.java | 2 +- .../ParseMetricRepository.java | 3 +- .../java/app/parse/ParseMetricsRecorder.java | 4 +- .../java/app/parse/ParseMetricsService.java | Bin 7003 -> 6903 bytes .../main/java/app/parse/ParseProgress.java | 2 +- .../entity => parse}/ParseSurvey.java | 2 +- .../{api => parse}/ParseSurveyController.java | 7 +- .../ParseSurveyRepository.java | 3 +- .../java/app/parse/ParsedExamPersister.java | 18 +- .../src/main/java/app/parse/SurveyDto.java | 14 ++ .../{api => section}/FigureController.java | 20 +-- .../entity => section}/Section.java | 2 +- .../entity => section}/SectionBlock.java | 2 +- .../SectionBlockController.java | 24 +-- .../SectionBlockRepository.java | 3 +- .../{api => section}/SectionController.java | 36 ++-- .../main/java/app/section/SectionDtos.java | 41 +++++ .../entity => section}/SectionFigure.java | 2 +- .../SectionFigureRepository.java | 3 +- .../SectionRepository.java | 3 +- .../main/java/app/section/SectionService.java | 110 +++++++++++++ .../main/java/app/security/CurrentUser.java | 2 +- .../app/security/StaticTokenAuthFilter.java | 2 +- .../main/java/app/{api => shared}/Access.java | 10 +- .../{persistence => shared}/DefaultUser.java | 2 +- .../main/java/app/{api => shared}/Patch.java | 22 +-- .../src/main/java/app/solve/SolveCore.java | 20 +-- .../main/java/app/solve/SolveExamService.java | 16 +- .../java/app/solve/SolveSectionService.java | 14 +- .../main/java/app/solve/SolveTaskService.java | 8 +- .../src/main/java/app/sse/SseController.java | 2 +- .../app/{api => storage}/FileController.java | 9 +- .../{persistence/entity => task}/Task.java | 2 +- .../entity => task}/TaskAnswer.java | 2 +- .../{api => task}/TaskAnswerController.java | 8 +- .../TaskAnswerRepository.java | 3 +- .../app/{api => task}/TaskController.java | 25 ++- .../src/main/java/app/task/TaskDtos.java | 36 ++++ .../entity => task}/TaskOption.java | 2 +- .../repository => task}/TaskRepository.java | 3 +- .../src/main/java/app/task/TaskService.java | 34 ++++ .../app/{api => }/OwnershipIsolationIT.java | 9 +- .../{persistence => }/SchemaIntegrityIT.java | 25 ++- .../CrudOperationsIT.java} | 44 ++--- .../{api => exam}/ExamProgressServiceIT.java | 20 +-- .../lgh/TaskGoalGenerationServiceTest.java | 10 +- .../app/parse/ParseMetricsServiceTest.java | 2 - .../parse/ParsedExamNormalizationTest.java | 2 +- .../security/StaticTokenAuthFilterTest.java | 2 +- .../java/app/{api => shared}/AccessTest.java | 8 +- .../app/solve/SolveExamServiceAsyncTest.java | 16 +- .../java/app/solve/SolveExamServiceTest.java | 14 +- .../{api => storage}/FileControllerIT.java | 9 +- 72 files changed, 643 insertions(+), 573 deletions(-) rename apps/examlense/backend/src/main/java/app/{api => admin}/AdminController.java (79%) create mode 100644 apps/examlense/backend/src/main/java/app/admin/SurveyModelDto.java delete mode 100644 apps/examlense/backend/src/main/java/app/api/Dtos.java rename apps/examlense/backend/src/main/java/app/{persistence/entity => exam}/Exam.java (99%) rename apps/examlense/backend/src/main/java/app/{api => exam}/ExamController.java (86%) create mode 100644 apps/examlense/backend/src/main/java/app/exam/ExamDtos.java rename apps/examlense/backend/src/main/java/app/{api => exam}/ExamProgressService.java (92%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => exam}/ExamRepository.java (97%) rename apps/examlense/backend/src/main/java/app/{api/CrudService.java => exam/ExamService.java} (60%) create mode 100644 apps/examlense/backend/src/main/java/app/grading/GradeDto.java rename apps/examlense/backend/src/main/java/app/{persistence/entity => grading}/TaskGrade.java (98%) rename apps/examlense/backend/src/main/java/app/{api => grading}/TaskGradeController.java (82%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => grading}/TaskGradeRepository.java (90%) rename apps/examlense/backend/src/main/java/app/{persistence/entity => parse}/ParseMetric.java (98%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => parse}/ParseMetricRepository.java (68%) rename apps/examlense/backend/src/main/java/app/{persistence/entity => parse}/ParseSurvey.java (98%) rename apps/examlense/backend/src/main/java/app/{api => parse}/ParseSurveyController.java (92%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => parse}/ParseSurveyRepository.java (94%) create mode 100644 apps/examlense/backend/src/main/java/app/parse/SurveyDto.java rename apps/examlense/backend/src/main/java/app/{api => section}/FigureController.java (89%) rename apps/examlense/backend/src/main/java/app/{persistence/entity => section}/Section.java (98%) rename apps/examlense/backend/src/main/java/app/{persistence/entity => section}/SectionBlock.java (98%) rename apps/examlense/backend/src/main/java/app/{api => section}/SectionBlockController.java (82%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => section}/SectionBlockRepository.java (92%) rename apps/examlense/backend/src/main/java/app/{api => section}/SectionController.java (75%) create mode 100644 apps/examlense/backend/src/main/java/app/section/SectionDtos.java rename apps/examlense/backend/src/main/java/app/{persistence/entity => section}/SectionFigure.java (98%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => section}/SectionFigureRepository.java (76%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => section}/SectionRepository.java (97%) create mode 100644 apps/examlense/backend/src/main/java/app/section/SectionService.java rename apps/examlense/backend/src/main/java/app/{api => shared}/Access.java (93%) rename apps/examlense/backend/src/main/java/app/{persistence => shared}/DefaultUser.java (95%) rename apps/examlense/backend/src/main/java/app/{api => shared}/Patch.java (84%) rename apps/examlense/backend/src/main/java/app/{api => storage}/FileController.java (95%) rename apps/examlense/backend/src/main/java/app/{persistence/entity => task}/Task.java (99%) rename apps/examlense/backend/src/main/java/app/{persistence/entity => task}/TaskAnswer.java (98%) rename apps/examlense/backend/src/main/java/app/{api => task}/TaskAnswerController.java (79%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => task}/TaskAnswerRepository.java (92%) rename apps/examlense/backend/src/main/java/app/{api => task}/TaskController.java (86%) create mode 100644 apps/examlense/backend/src/main/java/app/task/TaskDtos.java rename apps/examlense/backend/src/main/java/app/{persistence/entity => task}/TaskOption.java (91%) rename apps/examlense/backend/src/main/java/app/{persistence/repository => task}/TaskRepository.java (96%) create mode 100644 apps/examlense/backend/src/main/java/app/task/TaskService.java rename apps/examlense/backend/src/test/java/app/{api => }/OwnershipIsolationIT.java (96%) rename apps/examlense/backend/src/test/java/app/{persistence => }/SchemaIntegrityIT.java (88%) rename apps/examlense/backend/src/test/java/app/{api/CrudServiceIT.java => exam/CrudOperationsIT.java} (85%) rename apps/examlense/backend/src/test/java/app/{api => exam}/ExamProgressServiceIT.java (91%) rename apps/examlense/backend/src/test/java/app/{api => shared}/AccessTest.java (94%) rename apps/examlense/backend/src/test/java/app/{api => storage}/FileControllerIT.java (95%) diff --git a/apps/examlense/backend/README.md b/apps/examlense/backend/README.md index 3ca9a8b..a681ffe 100644 --- a/apps/examlense/backend/README.md +++ b/apps/examlense/backend/README.md @@ -99,22 +99,35 @@ every request, and `src/lib/sse.ts` opens the SSE streams. backend/ ├── build.gradle.kts ├── settings.gradle.kts -├── src/main/java/app/ -│ ├── ApiApplication.java -│ ├── security/ # StaticTokenAuthFilter, RateLimitFilter, CurrentUser -│ ├── config/ # SecurityConfig, AsyncConfig (solver/LGH pools, scheduling) -│ ├── api/ # CRUD controllers + CrudService + Dtos (snake_case) + Access/Patch helpers -│ ├── error/ # ApiException + GlobalExceptionHandler -│ ├── persistence/ # JPA entities + repositories + DefaultUser -│ ├── parse/ # parse pipeline: ParseExamService (orchestrator), -│ │ # ParseInputBuilder, ParsedExamPersister, ParseProgress, metrics +├── src/main/java/app/ # package-by-feature: each domain slice owns its +│ ├── ApiApplication.java # controller + service + entity + repository + DTOs +│ │ +│ │ # ── domain feature slices ── +│ ├── exam/ # ExamController, ExamService (duplication), ExamProgressService, +│ │ # Exam entity + repo, ExamDtos (snake_case) +│ ├── section/ # Section/Block/Figure controllers, SectionService (insert/unconfirm/ +│ │ # delete), Section/SectionBlock/SectionFigure entities + repos, SectionDtos +│ ├── task/ # Task + TaskAnswer controllers, TaskService (insert), +│ │ # Task/TaskOption/TaskAnswer entities + repos, TaskDtos +│ ├── grading/ # TaskGradeController, TaskGrade entity + repo, GradeDto +│ ├── parse/ # parse pipeline: ParseExamService (orchestrator), ParseInputBuilder, +│ │ # ParsedExamPersister, ParseProgress, metrics + parse-quality survey │ ├── solve/ # SolveExam/Section/Task services + SolveCore (shared machinery) +│ ├── lgh/ # LearningGoalHub proxy + goal generation +│ ├── admin/ # AdminController (survey/metrics rollups) + SurveyModelDto +│ │ +│ │ # ── shared technical infrastructure ── │ ├── ai/ # AiProvider + factory, ProviderHttpCaller (shared transport/retry), │ │ # per-provider request/response adapters, parser/solver strategies -│ ├── lgh/ # LearningGoalHub proxy + goal generation +│ ├── storage/ # StorageService, LocalFileSystemStorageService, SignedUrls, FileController +│ ├── sse/ # SseHub + SseController +│ ├── security/ # StaticTokenAuthFilter, RateLimitFilter, CurrentUser +│ ├── config/ # SecurityConfig, AsyncConfig (solver/LGH pools, scheduling) +│ ├── error/ # ApiException + GlobalExceptionHandler │ ├── prompts/ # solver prompt + submit_answers schema -│ ├── storage/ # StorageService, LocalFileSystemStorageService, SignedUrls -│ └── sse/ # SseHub + SseController +│ ├── models/ # ModelsController (parser/solver model catalog endpoint) +│ ├── health/ # HealthController +│ └── shared/ # Access + Patch (ownership / PATCH-body helpers), DefaultUser └── src/main/resources/ ├── application.yml └── db/migration/ # Flyway (V1__baseline.sql … V5) diff --git a/apps/examlense/backend/src/main/java/app/api/AdminController.java b/apps/examlense/backend/src/main/java/app/admin/AdminController.java similarity index 79% rename from apps/examlense/backend/src/main/java/app/api/AdminController.java rename to apps/examlense/backend/src/main/java/app/admin/AdminController.java index c494d51..6380a8a 100644 --- a/apps/examlense/backend/src/main/java/app/api/AdminController.java +++ b/apps/examlense/backend/src/main/java/app/admin/AdminController.java @@ -1,6 +1,8 @@ -package app.api; +package app.admin; -import app.persistence.repository.ParseSurveyRepository; +import app.parse.SurveyDto; + +import app.parse.ParseSurveyRepository; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -22,13 +24,13 @@ public AdminController(ParseSurveyRepository surveyRepository) { } @GetMapping("/survey") - public List survey() { - return surveyRepository.findAllByOrderByCreatedAtDesc().stream().map(Dtos.SurveyDto::from).toList(); + public List survey() { + return surveyRepository.findAllByOrderByCreatedAtDesc().stream().map(SurveyDto::from).toList(); } /** Parsing-quality survey scores grouped by parser model — which model does the job best. */ @GetMapping("/survey-by-model") - public List surveyByModel() { - return surveyRepository.aggregateByModel().stream().map(Dtos.SurveyModelDto::from).toList(); + public List surveyByModel() { + return surveyRepository.aggregateByModel().stream().map(SurveyModelDto::from).toList(); } } diff --git a/apps/examlense/backend/src/main/java/app/admin/SurveyModelDto.java b/apps/examlense/backend/src/main/java/app/admin/SurveyModelDto.java new file mode 100644 index 0000000..d0323f5 --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/admin/SurveyModelDto.java @@ -0,0 +1,21 @@ +package app.admin; + +/** + * Per-model rollup of parsing-quality survey scores (see + * {@code app.parse.ParseSurveyRepository#aggregateByModel()}). Averages are null + * when no response for that model rated the aspect. + */ +public record SurveyModelDto( + String model_id, long responses, Double avg_speed, + Double avg_content_correctness, Double avg_structure +) { + public static SurveyModelDto from(Object[] row) { + return new SurveyModelDto( + (String) row[0], + ((Number) row[1]).longValue(), + row[2] == null ? null : ((Number) row[2]).doubleValue(), + row[3] == null ? null : ((Number) row[3]).doubleValue(), + row[4] == null ? null : ((Number) row[4]).doubleValue() + ); + } +} diff --git a/apps/examlense/backend/src/main/java/app/api/Dtos.java b/apps/examlense/backend/src/main/java/app/api/Dtos.java deleted file mode 100644 index e09e0ce..0000000 --- a/apps/examlense/backend/src/main/java/app/api/Dtos.java +++ /dev/null @@ -1,155 +0,0 @@ -package app.api; - -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.SectionFigure; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.entity.ParseSurvey; -import app.persistence.entity.TaskGrade; -import app.persistence.entity.TaskOption; -import com.fasterxml.jackson.annotation.JsonUnwrapped; -import java.math.BigDecimal; -import java.time.OffsetDateTime; -import java.util.List; -import java.util.UUID; - -/** - * Response DTOs returned to the frontend. Component names are snake_case to - * match the row shape the frontend already consumes from Supabase (e.g. - * {@code exam_id}, {@code learning_goal_ids}), so the Phase 3 cutover is a near - * drop-in. (We deliberately avoid a global Jackson snake_case strategy, which - * would also rename the existing camelCase {@code /api/parse-metrics} payload.) - */ -public final class Dtos { - - private Dtos() {} - - public record ExamDto( - UUID id, UUID owner_id, String title, String course, String semester, - String instructor_name, BigDecimal total_points, String language, String source, - String source_file_url, String status, String parse_error, String parse_phase, - String parser_model, String solver_model, String parse_raw_text, - Long lgh_course_id, OffsetDateTime created_at, OffsetDateTime updated_at - ) { - public static ExamDto from(Exam e) { - return new ExamDto(e.getId(), e.getOwnerId(), e.getTitle(), e.getCourse(), e.getSemester(), - e.getInstructorName(), e.getTotalPoints(), e.getLanguage(), e.getSource(), - e.getSourceFileUrl(), e.getStatus(), e.getParseError(), e.getParsePhase(), - e.getParserModel(), e.getSolverModel(), e.getParseRawText(), - e.getLghCourseId(), e.getCreatedAt(), e.getUpdatedAt()); - } - } - - /** - * List-only variant of {@link ExamDto} that additionally carries the exam's - * progress counts for the dashboard table. {@code @JsonUnwrapped} flattens - * the exam fields so the JSON shape stays a superset of {@code ExamDto} - * (the frontend reads {@code ExamListItem extends Exam}). The plain - * {@code ExamDto} is left untouched for single-exam endpoints. - */ - public record ExamListItemDto( - @JsonUnwrapped ExamDto exam, - long task_count, long scored_count, long answered_count, long graded_count - ) { - public static ExamListItemDto from(Exam e, ExamProgressService.Counts c) { - return new ExamListItemDto(ExamDto.from(e), - c.taskCount(), c.scoredCount(), c.answeredCount(), c.gradedCount()); - } - } - - public record SectionDto( - UUID id, UUID exam_id, int position, String name, - OffsetDateTime confirmed_at, OffsetDateTime solve_started_at, - OffsetDateTime created_at, OffsetDateTime updated_at - ) { - public static SectionDto from(Section s) { - return new SectionDto(s.getId(), s.getExamId(), s.getPosition(), s.getName(), - s.getConfirmedAt(), s.getSolveStartedAt(), s.getCreatedAt(), s.getUpdatedAt()); - } - } - - public record TaskDto( - UUID id, UUID exam_id, UUID section_id, int position, String section, String type, - String prompt, List options, String reference_answer, BigDecimal points, - String parse_confidence, List learning_goal_ids, - OffsetDateTime created_at, OffsetDateTime updated_at - ) { - public static TaskDto from(Task t) { - return new TaskDto(t.getId(), t.getExamId(), t.getSectionId(), t.getPosition(), - t.getSection(), t.getType(), t.getPrompt(), t.getOptions(), t.getReferenceAnswer(), - t.getPoints(), t.getParseConfidence(), t.getLearningGoalIds(), - t.getCreatedAt(), t.getUpdatedAt()); - } - } - - public record BlockDto( - UUID id, UUID exam_id, UUID section_id, int position, String content, String kind, - OffsetDateTime created_at, OffsetDateTime updated_at - ) { - public static BlockDto from(SectionBlock b) { - return new BlockDto(b.getId(), b.getExamId(), b.getSectionId(), b.getPosition(), - b.getContent(), b.getKind(), b.getCreatedAt(), b.getUpdatedAt()); - } - } - - public record FigureDto( - UUID id, UUID block_id, String storage_path, String caption, int position, - String source, OffsetDateTime created_at - ) { - public static FigureDto from(SectionFigure f) { - return new FigureDto(f.getId(), f.getBlockId(), f.getStoragePath(), f.getCaption(), - f.getPosition(), f.getSource(), f.getCreatedAt()); - } - } - - public record AnswerDto( - UUID id, UUID task_id, UUID exam_id, List selected_option_ids, - String answer_text, String reasoning, String provider, String model, OffsetDateTime created_at - ) { - public static AnswerDto from(TaskAnswer a) { - return new AnswerDto(a.getId(), a.getTaskId(), a.getExamId(), a.getSelectedOptionIds(), - a.getAnswerText(), a.getReasoning(), a.getProvider(), a.getModel(), a.getCreatedAt()); - } - } - - public record SurveyDto( - UUID id, UUID exam_id, Short speed, Short content_correctness, Short structure, OffsetDateTime created_at - ) { - public static SurveyDto from(ParseSurvey s) { - return new SurveyDto(s.getId(), s.getExamId(), s.getSpeed(), s.getContentCorrectness(), - s.getStructure(), s.getCreatedAt()); - } - } - - /** - * Per-model rollup of parsing-quality survey scores (see - * {@link app.persistence.repository.ParseSurveyRepository#aggregateByModel()}). - * Averages are null when no response for that model rated the aspect. - */ - public record SurveyModelDto( - String model_id, long responses, Double avg_speed, - Double avg_content_correctness, Double avg_structure - ) { - public static SurveyModelDto from(Object[] row) { - return new SurveyModelDto( - (String) row[0], - ((Number) row[1]).longValue(), - row[2] == null ? null : ((Number) row[2]).doubleValue(), - row[3] == null ? null : ((Number) row[3]).doubleValue(), - row[4] == null ? null : ((Number) row[4]).doubleValue() - ); - } - } - - public record GradeDto( - UUID id, UUID task_id, UUID exam_id, BigDecimal score, boolean auto_graded, - String feedback, UUID graded_by, OffsetDateTime created_at, OffsetDateTime updated_at - ) { - public static GradeDto from(TaskGrade g) { - return new GradeDto(g.getId(), g.getTaskId(), g.getExamId(), g.getScore(), g.isAutoGraded(), - g.getFeedback(), g.getGradedBy(), g.getCreatedAt(), g.getUpdatedAt()); - } - } -} diff --git a/apps/examlense/backend/src/main/java/app/config/SecurityConfig.java b/apps/examlense/backend/src/main/java/app/config/SecurityConfig.java index 3ac267f..1e18839 100644 --- a/apps/examlense/backend/src/main/java/app/config/SecurityConfig.java +++ b/apps/examlense/backend/src/main/java/app/config/SecurityConfig.java @@ -21,7 +21,7 @@ * Stateless API security. Real user authentication is deferred to a later phase; * for now the API is gated by a single static bearer token plus a coarse per-IP * rate limiter (bot/overload protection only). A valid token authenticates the - * request as the single seeded user (see {@link app.persistence.DefaultUser}). + * request as the single seeded user (see {@link app.shared.DefaultUser}). * * Public endpoints: /api/healthz (liveness probe) and CORS preflight. */ diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/Exam.java b/apps/examlense/backend/src/main/java/app/exam/Exam.java similarity index 99% rename from apps/examlense/backend/src/main/java/app/persistence/entity/Exam.java rename to apps/examlense/backend/src/main/java/app/exam/Exam.java index 1a99ec2..9835a8b 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/Exam.java +++ b/apps/examlense/backend/src/main/java/app/exam/Exam.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.exam; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/api/ExamController.java b/apps/examlense/backend/src/main/java/app/exam/ExamController.java similarity index 86% rename from apps/examlense/backend/src/main/java/app/api/ExamController.java rename to apps/examlense/backend/src/main/java/app/exam/ExamController.java index dad70b3..159e2a4 100644 --- a/apps/examlense/backend/src/main/java/app/api/ExamController.java +++ b/apps/examlense/backend/src/main/java/app/exam/ExamController.java @@ -1,8 +1,8 @@ -package app.api; +package app.exam; +import app.shared.Patch; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; import app.security.CurrentUser; import app.storage.StorageService; import java.util.List; @@ -32,36 +32,36 @@ public record CreateExamRequest( private final ExamRepository examRepository; private final Access access; - private final CrudService crud; + private final ExamService examService; private final StorageService storage; private final ExamProgressService progress; - public ExamController(ExamRepository examRepository, Access access, CrudService crud, + public ExamController(ExamRepository examRepository, Access access, ExamService examService, StorageService storage, ExamProgressService progress) { this.examRepository = examRepository; this.access = access; - this.crud = crud; + this.examService = examService; this.storage = storage; this.progress = progress; } @GetMapping - public List list(@CurrentUser String userId) { + public List list(@CurrentUser String userId) { List exams = examRepository.findByOwnerIdOrderByCreatedAtDesc(UUID.fromString(userId)); var counts = progress.countsFor(exams.stream().map(Exam::getId).toList()); return exams.stream() - .map(e -> Dtos.ExamListItemDto.from(e, + .map(e -> ExamDtos.ExamListItemDto.from(e, counts.getOrDefault(e.getId(), ExamProgressService.Counts.EMPTY))) .toList(); } @GetMapping("/{id}") - public Dtos.ExamDto get(@PathVariable String id, @CurrentUser String userId) { - return Dtos.ExamDto.from(access.requireExam(Access.id(id), userId)); + public ExamDtos.ExamDto get(@PathVariable String id, @CurrentUser String userId) { + return ExamDtos.ExamDto.from(access.requireExam(Access.id(id), userId)); } @PostMapping - public Dtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser String userId) { + public ExamDtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser String userId) { Exam e = new Exam(); e.setOwnerId(UUID.fromString(userId)); if (req.title() != null) e.setTitle(req.title()); @@ -75,7 +75,7 @@ public Dtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser Stri if (req.parser_model() != null) e.setParserModel(req.parser_model()); if (req.solver_model() != null) e.setSolverModel(req.solver_model()); if (req.lgh_course_id() != null) e.setLghCourseId(req.lgh_course_id()); - return Dtos.ExamDto.from(examRepository.save(e)); + return ExamDtos.ExamDto.from(examRepository.save(e)); } /** Statuses the client may set directly (mirrors the DB check constraint). */ @@ -83,7 +83,7 @@ public Dtos.ExamDto create(@RequestBody CreateExamRequest req, @CurrentUser Stri Set.of("draft", "parsing", "failed", "ready", "evaluating", "grading", "finished"); @PatchMapping("/{id}") - public Dtos.ExamDto patch(@PathVariable String id, @RequestBody Map body, + public ExamDtos.ExamDto patch(@PathVariable String id, @RequestBody Map body, @CurrentUser String userId) { Exam e = access.requireExam(Access.id(id), userId); if (Patch.has(body, "title")) e.setTitle(Patch.str(body.get("title"))); @@ -118,7 +118,7 @@ public Dtos.ExamDto patch(@PathVariable String id, @RequestBody Map delete(@PathVariable String id, @CurrentUser String * if the exam is not currently processing. */ @PostMapping("/{id}/cancel") - public Dtos.ExamDto cancel(@PathVariable String id, @CurrentUser String userId) { + public ExamDtos.ExamDto cancel(@PathVariable String id, @CurrentUser String userId) { Exam e = access.requireExam(Access.id(id), userId); // 404 if missing, 403 if not owner int updated = examRepository.cancelProcessing(e.getId(), "Cancelled."); if (updated == 0) { throw new ApiException(HttpStatus.CONFLICT, "Exam is not currently processing"); } - return Dtos.ExamDto.from(examRepository.findById(e.getId()).orElse(e)); + return ExamDtos.ExamDto.from(examRepository.findById(e.getId()).orElse(e)); } @PostMapping("/{id}/duplicate") - public Dtos.ExamDto duplicate(@PathVariable String id, @CurrentUser String userId) { + public ExamDtos.ExamDto duplicate(@PathVariable String id, @CurrentUser String userId) { Exam src = access.requireExam(Access.id(id), userId); - return Dtos.ExamDto.from(crud.duplicateExam(src, userId)); + return ExamDtos.ExamDto.from(examService.duplicateExam(src, userId)); } } diff --git a/apps/examlense/backend/src/main/java/app/exam/ExamDtos.java b/apps/examlense/backend/src/main/java/app/exam/ExamDtos.java new file mode 100644 index 0000000..32592f6 --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/exam/ExamDtos.java @@ -0,0 +1,50 @@ +package app.exam; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Response DTOs for the exam slice. Component names are snake_case to match the + * row shape the frontend consumes (e.g. {@code lgh_course_id}); we deliberately + * avoid a global Jackson snake_case strategy so other camelCase payloads (e.g. + * {@code /api/parse-metrics}) are unaffected. + */ +public final class ExamDtos { + + private ExamDtos() {} + + public record ExamDto( + UUID id, UUID owner_id, String title, String course, String semester, + String instructor_name, BigDecimal total_points, String language, String source, + String source_file_url, String status, String parse_error, String parse_phase, + String parser_model, String solver_model, String parse_raw_text, + Long lgh_course_id, OffsetDateTime created_at, OffsetDateTime updated_at + ) { + public static ExamDto from(Exam e) { + return new ExamDto(e.getId(), e.getOwnerId(), e.getTitle(), e.getCourse(), e.getSemester(), + e.getInstructorName(), e.getTotalPoints(), e.getLanguage(), e.getSource(), + e.getSourceFileUrl(), e.getStatus(), e.getParseError(), e.getParsePhase(), + e.getParserModel(), e.getSolverModel(), e.getParseRawText(), + e.getLghCourseId(), e.getCreatedAt(), e.getUpdatedAt()); + } + } + + /** + * List-only variant of {@link ExamDto} that additionally carries the exam's + * progress counts for the dashboard table. {@code @JsonUnwrapped} flattens + * the exam fields so the JSON shape stays a superset of {@code ExamDto} + * (the frontend reads {@code ExamListItem extends Exam}). The plain + * {@code ExamDto} is left untouched for single-exam endpoints. + */ + public record ExamListItemDto( + @JsonUnwrapped ExamDto exam, + long task_count, long scored_count, long answered_count, long graded_count + ) { + public static ExamListItemDto from(Exam e, ExamProgressService.Counts c) { + return new ExamListItemDto(ExamDto.from(e), + c.taskCount(), c.scoredCount(), c.answeredCount(), c.gradedCount()); + } + } +} diff --git a/apps/examlense/backend/src/main/java/app/api/ExamProgressService.java b/apps/examlense/backend/src/main/java/app/exam/ExamProgressService.java similarity index 92% rename from apps/examlense/backend/src/main/java/app/api/ExamProgressService.java rename to apps/examlense/backend/src/main/java/app/exam/ExamProgressService.java index 20fd192..2187c0a 100644 --- a/apps/examlense/backend/src/main/java/app/api/ExamProgressService.java +++ b/apps/examlense/backend/src/main/java/app/exam/ExamProgressService.java @@ -1,11 +1,11 @@ -package app.api; +package app.exam; -import app.persistence.entity.Task; -import app.persistence.entity.TaskGrade; -import app.persistence.entity.TaskOption; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskGradeRepository; -import app.persistence.repository.TaskRepository; +import app.task.Task; +import app.grading.TaskGrade; +import app.task.TaskOption; +import app.task.TaskAnswerRepository; +import app.grading.TaskGradeRepository; +import app.task.TaskRepository; import java.math.BigDecimal; import java.util.HashMap; import java.util.HashSet; diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/ExamRepository.java b/apps/examlense/backend/src/main/java/app/exam/ExamRepository.java similarity index 97% rename from apps/examlense/backend/src/main/java/app/persistence/repository/ExamRepository.java rename to apps/examlense/backend/src/main/java/app/exam/ExamRepository.java index 1e15d38..b2d0e0f 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/ExamRepository.java +++ b/apps/examlense/backend/src/main/java/app/exam/ExamRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.exam; -import app.persistence.entity.Exam; import java.util.List; import java.util.Optional; import java.util.UUID; diff --git a/apps/examlense/backend/src/main/java/app/api/CrudService.java b/apps/examlense/backend/src/main/java/app/exam/ExamService.java similarity index 60% rename from apps/examlense/backend/src/main/java/app/api/CrudService.java rename to apps/examlense/backend/src/main/java/app/exam/ExamService.java index 1c32c1d..d830a0e 100644 --- a/apps/examlense/backend/src/main/java/app/api/CrudService.java +++ b/apps/examlense/backend/src/main/java/app/exam/ExamService.java @@ -1,19 +1,14 @@ -package app.api; - -import app.lgh.TaskGoalGenerationService; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.SectionFigure; -import app.persistence.entity.Task; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionFigureRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskRepository; -import app.sse.SseHub; +package app.exam; + +import app.section.Section; +import app.section.SectionBlock; +import app.section.SectionBlockRepository; +import app.section.SectionFigure; +import app.section.SectionFigureRepository; +import app.section.SectionRepository; import app.storage.StorageService; +import app.task.Task; +import app.task.TaskRepository; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -23,12 +18,14 @@ import org.springframework.transaction.annotation.Transactional; /** - * Multi-step CRUD operations that must be atomic: position-shifting inserts - * (ports of the {@code shift_and_insert_*} DB functions), full exam duplication, - * and section unconfirm (which also clears the section's AI answers). + * Exam-level operations that must be atomic — currently full exam duplication + * (exam → sections → blocks → tasks → figures, with fresh storage objects). + * Walks the child aggregates' repositories directly; that cross-slice reach is + * inherent to a deep-copy and kept explicit here rather than hidden in a shared + * god-service. */ @Service -public class CrudService { +public class ExamService { private static final String PDF_BUCKET = "exam-pdfs"; private static final String FIGURE_BUCKET = "exam-figures"; @@ -38,104 +35,19 @@ public class CrudService { private final TaskRepository tasks; private final SectionBlockRepository blocks; private final SectionFigureRepository figures; - private final TaskAnswerRepository answers; - private final TaskGoalGenerationService goalGeneration; - private final SseHub sse; private final StorageService storage; - public CrudService(ExamRepository exams, SectionRepository sections, TaskRepository tasks, + public ExamService(ExamRepository exams, SectionRepository sections, TaskRepository tasks, SectionBlockRepository blocks, SectionFigureRepository figures, - TaskAnswerRepository answers, TaskGoalGenerationService goalGeneration, - SseHub sse, StorageService storage) { + StorageService storage) { this.exams = exams; this.sections = sections; this.tasks = tasks; this.blocks = blocks; this.figures = figures; - this.answers = answers; - this.goalGeneration = goalGeneration; - this.sse = sse; this.storage = storage; } - /** Insert a section at {@code position}, shifting later sections down. */ - @Transactional - public Section addSection(Section section) { - sections.shiftSectionsFrom(section.getExamId(), section.getPosition()); - return sections.save(section); - } - - /** Insert a task at its position, shifting same-bucket tasks (and section blocks) down. */ - @Transactional - public Task addTask(Task task) { - if (task.getSectionId() != null) { - tasks.shiftTasksInSection(task.getExamId(), task.getSectionId(), task.getPosition()); - blocks.shiftBlocksInSection(task.getSectionId(), task.getPosition()); - } else { - tasks.shiftTasksNullSection(task.getExamId(), task.getPosition()); - } - return tasks.save(task); - } - - /** Insert a block at its position, shifting that section's blocks and tasks down. */ - @Transactional - public SectionBlock addBlock(SectionBlock block) { - blocks.shiftBlocksInSection(block.getSectionId(), block.getPosition()); - tasks.shiftTasksInSection(block.getExamId(), block.getSectionId(), block.getPosition()); - return blocks.save(block); - } - - /** - * Clear a section's confirmation, drop the AI answers for its tasks, and - * detach the LGH-derived learning goals (deleted in LGH async best-effort - * — the goals were generated for the now-retracted content). - */ - @Transactional - public void unconfirmSection(Section section) { - List taskIds = new ArrayList<>(); - List goalIds = new ArrayList<>(); - List sectionTasks = tasks.findBySectionIdOrderByPositionAsc(section.getId()); - for (Task t : sectionTasks) { - taskIds.add(t.getId()); - if (t.getLearningGoalIds() != null) { - goalIds.addAll(t.getLearningGoalIds()); - t.setLearningGoalIds(null); - } - } - if (!taskIds.isEmpty()) answers.deleteByTaskIdIn(taskIds); - tasks.saveAll(sectionTasks); - section.setConfirmedAt(null); - sections.save(section); - - if (!goalIds.isEmpty()) { - Exam exam = exams.findById(section.getExamId()).orElse(null); - if (exam != null && exam.getLghCourseId() != null) { - try { - goalGeneration.dispatchCleanup(exam.getLghCourseId(), goalIds); - } catch (RuntimeException ignored) {} - } - } - // Answers were dropped regardless of whether goals existed — always - // tell SSE subscribers the tasks changed. - sse.tasksUpdated(section.getExamId()); - } - - /** - * Delete a section with full cleanup, server-side: unconfirm first when - * needed (drops AI answers + detaches LGH goals), then remove its tasks - * and blocks along with the section — the frontend is not trusted to call - * the delete-by-section endpoints beforehand. - */ - @Transactional - public void deleteSection(Section section) { - if (section.getConfirmedAt() != null) { - unconfirmSection(section); - } - tasks.deleteByExamIdAndSectionId(section.getExamId(), section.getId()); - blocks.deleteByExamIdAndSectionId(section.getExamId(), section.getId()); - sections.delete(section); - } - /** * Deep-copy an exam: exam → sections → blocks → tasks → figures, remapping * section and block ids. The source PDF and each figure image are copied to diff --git a/apps/examlense/backend/src/main/java/app/grading/GradeDto.java b/apps/examlense/backend/src/main/java/app/grading/GradeDto.java new file mode 100644 index 0000000..8a4ec7c --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/grading/GradeDto.java @@ -0,0 +1,16 @@ +package app.grading; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** Response DTO for a task grade (auto or manual). */ +public record GradeDto( + UUID id, UUID task_id, UUID exam_id, BigDecimal score, boolean auto_graded, + String feedback, UUID graded_by, OffsetDateTime created_at, OffsetDateTime updated_at +) { + public static GradeDto from(TaskGrade g) { + return new GradeDto(g.getId(), g.getTaskId(), g.getExamId(), g.getScore(), g.isAutoGraded(), + g.getFeedback(), g.getGradedBy(), g.getCreatedAt(), g.getUpdatedAt()); + } +} diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/TaskGrade.java b/apps/examlense/backend/src/main/java/app/grading/TaskGrade.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/TaskGrade.java rename to apps/examlense/backend/src/main/java/app/grading/TaskGrade.java index bc26a77..e83e7ca 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/TaskGrade.java +++ b/apps/examlense/backend/src/main/java/app/grading/TaskGrade.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.grading; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/api/TaskGradeController.java b/apps/examlense/backend/src/main/java/app/grading/TaskGradeController.java similarity index 82% rename from apps/examlense/backend/src/main/java/app/api/TaskGradeController.java rename to apps/examlense/backend/src/main/java/app/grading/TaskGradeController.java index 492dd2b..ced2024 100644 --- a/apps/examlense/backend/src/main/java/app/api/TaskGradeController.java +++ b/apps/examlense/backend/src/main/java/app/grading/TaskGradeController.java @@ -1,11 +1,10 @@ -package app.api; +package app.grading; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.entity.TaskGrade; -import app.persistence.repository.TaskGradeRepository; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.task.Task; +import app.task.TaskRepository; import app.security.CurrentUser; import java.math.BigDecimal; import java.util.List; @@ -36,10 +35,10 @@ public TaskGradeController(TaskGradeRepository gradeRepository, TaskRepository t } @GetMapping("/exams/{examId}/grades") - public List list(@PathVariable String examId, @CurrentUser String userId) { + public List list(@PathVariable String examId, @CurrentUser String userId) { access.requireExam(Access.id(examId), userId); return gradeRepository.findByExamId(Access.id(examId)) - .stream().map(Dtos.GradeDto::from).toList(); + .stream().map(GradeDto::from).toList(); } /** @@ -49,7 +48,7 @@ public List list(@PathVariable String examId, @CurrentUser String * task in someone else's exam. */ @PutMapping("/task-grades") - public Dtos.GradeDto upsert(@RequestBody UpsertGradeRequest req, @CurrentUser String userId) { + public GradeDto upsert(@RequestBody UpsertGradeRequest req, @CurrentUser String userId) { Task task = access.requireOwnedChild(taskRepository, req.task_id(), userId, Task::getExamId, "Task"); // Grades are only editable while the exam is being graded. A finished exam // must first be re-opened (status → grading) before any score changes; this @@ -67,6 +66,6 @@ public Dtos.GradeDto upsert(@RequestBody UpsertGradeRequest req, @CurrentUser St g.setAutoGraded(req.auto_graded()); g.setFeedback(req.feedback()); g.setGradedBy(UUID.fromString(userId)); - return Dtos.GradeDto.from(gradeRepository.save(g)); + return GradeDto.from(gradeRepository.save(g)); } } diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/TaskGradeRepository.java b/apps/examlense/backend/src/main/java/app/grading/TaskGradeRepository.java similarity index 90% rename from apps/examlense/backend/src/main/java/app/persistence/repository/TaskGradeRepository.java rename to apps/examlense/backend/src/main/java/app/grading/TaskGradeRepository.java index 4bbeb5d..cfc1914 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/TaskGradeRepository.java +++ b/apps/examlense/backend/src/main/java/app/grading/TaskGradeRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.grading; -import app.persistence.entity.TaskGrade; import java.util.List; import java.util.Optional; import java.util.UUID; diff --git a/apps/examlense/backend/src/main/java/app/lgh/LghController.java b/apps/examlense/backend/src/main/java/app/lgh/LghController.java index 01222ff..54f33e0 100644 --- a/apps/examlense/backend/src/main/java/app/lgh/LghController.java +++ b/apps/examlense/backend/src/main/java/app/lgh/LghController.java @@ -1,10 +1,10 @@ package app.lgh; -import app.api.Access; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.task.Task; +import app.task.TaskRepository; import app.security.CurrentUser; import java.util.HashSet; import java.util.List; diff --git a/apps/examlense/backend/src/main/java/app/lgh/TaskGoalGenerationService.java b/apps/examlense/backend/src/main/java/app/lgh/TaskGoalGenerationService.java index 93ff8ac..fe09386 100644 --- a/apps/examlense/backend/src/main/java/app/lgh/TaskGoalGenerationService.java +++ b/apps/examlense/backend/src/main/java/app/lgh/TaskGoalGenerationService.java @@ -1,14 +1,14 @@ package app.lgh; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.Task; -import app.persistence.entity.TaskOption; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.section.Section; +import app.section.SectionBlock; +import app.task.Task; +import app.task.TaskOption; +import app.exam.ExamRepository; +import app.section.SectionBlockRepository; +import app.section.SectionRepository; +import app.task.TaskRepository; import app.sse.SseHub; import java.time.OffsetDateTime; import java.util.ArrayList; diff --git a/apps/examlense/backend/src/main/java/app/parse/ParseExamService.java b/apps/examlense/backend/src/main/java/app/parse/ParseExamService.java index 1653dd2..e47951d 100644 --- a/apps/examlense/backend/src/main/java/app/parse/ParseExamService.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseExamService.java @@ -5,10 +5,10 @@ import app.ai.AiProviderFactory; import app.ai.ParserStrategies; import app.ai.ParserStrategy; -import app.api.Access; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; +import app.exam.Exam; +import app.exam.ExamRepository; import app.storage.StorageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/apps/examlense/backend/src/main/java/app/parse/ParseInputBuilder.java b/apps/examlense/backend/src/main/java/app/parse/ParseInputBuilder.java index 4a7ed68..2ab4128 100644 --- a/apps/examlense/backend/src/main/java/app/parse/ParseInputBuilder.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseInputBuilder.java @@ -2,7 +2,7 @@ import app.ai.AiProvider; import app.ai.ParserStrategy; -import app.persistence.repository.ExamRepository; +import app.exam.ExamRepository; import java.util.ArrayList; import java.util.Base64; import java.util.List; diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/ParseMetric.java b/apps/examlense/backend/src/main/java/app/parse/ParseMetric.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/ParseMetric.java rename to apps/examlense/backend/src/main/java/app/parse/ParseMetric.java index 62d7a48..e619e2f 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/ParseMetric.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseMetric.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.parse; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/ParseMetricRepository.java b/apps/examlense/backend/src/main/java/app/parse/ParseMetricRepository.java similarity index 68% rename from apps/examlense/backend/src/main/java/app/persistence/repository/ParseMetricRepository.java rename to apps/examlense/backend/src/main/java/app/parse/ParseMetricRepository.java index 5b9f29f..6ad3e4b 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/ParseMetricRepository.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseMetricRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.parse; -import app.persistence.entity.ParseMetric; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/examlense/backend/src/main/java/app/parse/ParseMetricsRecorder.java b/apps/examlense/backend/src/main/java/app/parse/ParseMetricsRecorder.java index 11b0680..8e04e9a 100644 --- a/apps/examlense/backend/src/main/java/app/parse/ParseMetricsRecorder.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseMetricsRecorder.java @@ -1,8 +1,6 @@ package app.parse; -import app.persistence.entity.ParseMetric; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.ParseMetricRepository; +import app.exam.ExamRepository; import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/apps/examlense/backend/src/main/java/app/parse/ParseMetricsService.java b/apps/examlense/backend/src/main/java/app/parse/ParseMetricsService.java index 200c0219e991561d2b338b00e0b4ba9784be9d8a..53e6c3650913ff629bd25d82173dc39b2989bafd 100644 GIT binary patch delta 10 Rcmca@_T6-X^v3!-QUD#J1rGoK delta 103 zcmexvdfRM*bWCDFfnGssQE_H*Nornls$ObdNoGlNl|99HCJYCL4Hw* a0=m+o)Pnrt%#!>f6y-s1QR|JuPow}}aw#1E diff --git a/apps/examlense/backend/src/main/java/app/parse/ParseProgress.java b/apps/examlense/backend/src/main/java/app/parse/ParseProgress.java index b527aa9..a7f3f90 100644 --- a/apps/examlense/backend/src/main/java/app/parse/ParseProgress.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseProgress.java @@ -1,6 +1,6 @@ package app.parse; -import app.persistence.repository.ExamRepository; +import app.exam.ExamRepository; import app.sse.SseHub; import java.util.UUID; import org.slf4j.Logger; diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/ParseSurvey.java b/apps/examlense/backend/src/main/java/app/parse/ParseSurvey.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/ParseSurvey.java rename to apps/examlense/backend/src/main/java/app/parse/ParseSurvey.java index cbd7392..f705f84 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/ParseSurvey.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseSurvey.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.parse; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/api/ParseSurveyController.java b/apps/examlense/backend/src/main/java/app/parse/ParseSurveyController.java similarity index 92% rename from apps/examlense/backend/src/main/java/app/api/ParseSurveyController.java rename to apps/examlense/backend/src/main/java/app/parse/ParseSurveyController.java index 08ff252..2925001 100644 --- a/apps/examlense/backend/src/main/java/app/api/ParseSurveyController.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseSurveyController.java @@ -1,8 +1,7 @@ -package app.api; +package app.parse; +import app.shared.Access; -import app.persistence.entity.Exam; -import app.persistence.entity.ParseSurvey; -import app.persistence.repository.ParseSurveyRepository; +import app.exam.Exam; import app.security.CurrentUser; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/ParseSurveyRepository.java b/apps/examlense/backend/src/main/java/app/parse/ParseSurveyRepository.java similarity index 94% rename from apps/examlense/backend/src/main/java/app/persistence/repository/ParseSurveyRepository.java rename to apps/examlense/backend/src/main/java/app/parse/ParseSurveyRepository.java index f4a5dc0..cbebffe 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/ParseSurveyRepository.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParseSurveyRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.parse; -import app.persistence.entity.ParseSurvey; import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/examlense/backend/src/main/java/app/parse/ParsedExamPersister.java b/apps/examlense/backend/src/main/java/app/parse/ParsedExamPersister.java index f4e8b95..68fd5cd 100644 --- a/apps/examlense/backend/src/main/java/app/parse/ParsedExamPersister.java +++ b/apps/examlense/backend/src/main/java/app/parse/ParsedExamPersister.java @@ -1,14 +1,14 @@ package app.parse; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.Task; -import app.persistence.entity.TaskOption; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.section.Section; +import app.section.SectionBlock; +import app.task.Task; +import app.task.TaskOption; +import app.exam.ExamRepository; +import app.section.SectionBlockRepository; +import app.section.SectionRepository; +import app.task.TaskRepository; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; diff --git a/apps/examlense/backend/src/main/java/app/parse/SurveyDto.java b/apps/examlense/backend/src/main/java/app/parse/SurveyDto.java new file mode 100644 index 0000000..b52f3a5 --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/parse/SurveyDto.java @@ -0,0 +1,14 @@ +package app.parse; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** Response DTO for a single parsing-quality survey response. */ +public record SurveyDto( + UUID id, UUID exam_id, Short speed, Short content_correctness, Short structure, OffsetDateTime created_at +) { + public static SurveyDto from(ParseSurvey s) { + return new SurveyDto(s.getId(), s.getExamId(), s.getSpeed(), s.getContentCorrectness(), + s.getStructure(), s.getCreatedAt()); + } +} diff --git a/apps/examlense/backend/src/main/java/app/api/FigureController.java b/apps/examlense/backend/src/main/java/app/section/FigureController.java similarity index 89% rename from apps/examlense/backend/src/main/java/app/api/FigureController.java rename to apps/examlense/backend/src/main/java/app/section/FigureController.java index 7b68f46..367dd94 100644 --- a/apps/examlense/backend/src/main/java/app/api/FigureController.java +++ b/apps/examlense/backend/src/main/java/app/section/FigureController.java @@ -1,10 +1,8 @@ -package app.api; +package app.section; +import app.shared.Patch; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.SectionFigure; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionFigureRepository; import app.security.CurrentUser; import app.storage.SignedUrls; import app.storage.StorageService; @@ -48,14 +46,14 @@ public FigureController(SectionFigureRepository figureRepository, SectionBlockRe } @GetMapping("/blocks/{blockId}/figures") - public List list(@PathVariable String blockId, @CurrentUser String userId) { + public List list(@PathVariable String blockId, @CurrentUser String userId) { requireBlock(Access.id(blockId), userId); return figureRepository.findByBlockIdOrderByPositionAsc(Access.id(blockId)) - .stream().map(Dtos.FigureDto::from).toList(); + .stream().map(SectionDtos.FigureDto::from).toList(); } @PostMapping("/blocks/{blockId}/figures") - public Dtos.FigureDto upload(@PathVariable String blockId, + public SectionDtos.FigureDto upload(@PathVariable String blockId, @RequestParam("file") MultipartFile file, @RequestParam(value = "position", required = false) Integer position, @CurrentUser String userId) throws IOException { @@ -77,7 +75,7 @@ public Dtos.FigureDto upload(@PathVariable String blockId, // Save the row first, then store the object — a failed DB write can't // leave an orphaned file; a failed store leaves a row the delete // endpoint can still clean up. - Dtos.FigureDto dto = Dtos.FigureDto.from(figureRepository.save(fig)); + SectionDtos.FigureDto dto = SectionDtos.FigureDto.from(figureRepository.save(fig)); try { storage.store(FIGURE_BUCKET, path, file.getBytes()); } catch (RuntimeException e) { @@ -88,12 +86,12 @@ public Dtos.FigureDto upload(@PathVariable String blockId, } @PatchMapping("/figures/{id}") - public Dtos.FigureDto patch(@PathVariable String id, @RequestBody Map body, + public SectionDtos.FigureDto patch(@PathVariable String id, @RequestBody Map body, @CurrentUser String userId) { SectionFigure fig = loadFigure(id, userId); if (Patch.has(body, "caption")) fig.setCaption(Patch.str(body.get("caption"))); if (Patch.has(body, "position")) fig.setPosition(Patch.intVal(body.get("position"))); - return Dtos.FigureDto.from(figureRepository.save(fig)); + return SectionDtos.FigureDto.from(figureRepository.save(fig)); } @DeleteMapping("/figures/{id}") diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/Section.java b/apps/examlense/backend/src/main/java/app/section/Section.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/Section.java rename to apps/examlense/backend/src/main/java/app/section/Section.java index 4a910e8..c1050bf 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/Section.java +++ b/apps/examlense/backend/src/main/java/app/section/Section.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.section; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/SectionBlock.java b/apps/examlense/backend/src/main/java/app/section/SectionBlock.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/SectionBlock.java rename to apps/examlense/backend/src/main/java/app/section/SectionBlock.java index 7f92939..a3d36a9 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/SectionBlock.java +++ b/apps/examlense/backend/src/main/java/app/section/SectionBlock.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.section; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/api/SectionBlockController.java b/apps/examlense/backend/src/main/java/app/section/SectionBlockController.java similarity index 82% rename from apps/examlense/backend/src/main/java/app/api/SectionBlockController.java rename to apps/examlense/backend/src/main/java/app/section/SectionBlockController.java index 3dbabbd..416442d 100644 --- a/apps/examlense/backend/src/main/java/app/api/SectionBlockController.java +++ b/apps/examlense/backend/src/main/java/app/section/SectionBlockController.java @@ -1,8 +1,8 @@ -package app.api; +package app.section; +import app.shared.Patch; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.SectionBlock; -import app.persistence.repository.SectionBlockRepository; import app.security.CurrentUser; import java.util.List; import java.util.Map; @@ -28,23 +28,23 @@ public record CreateBlockRequest(String exam_id, String section_id, Integer posi private final SectionBlockRepository blockRepository; private final Access access; - private final CrudService crud; + private final SectionService sectionService; - public SectionBlockController(SectionBlockRepository blockRepository, Access access, CrudService crud) { + public SectionBlockController(SectionBlockRepository blockRepository, Access access, SectionService sectionService) { this.blockRepository = blockRepository; this.access = access; - this.crud = crud; + this.sectionService = sectionService; } @GetMapping("/exams/{examId}/blocks") - public List list(@PathVariable String examId, @CurrentUser String userId) { + public List list(@PathVariable String examId, @CurrentUser String userId) { access.requireExam(Access.id(examId), userId); return blockRepository.findByExamIdOrderByPositionAsc(Access.id(examId)) - .stream().map(Dtos.BlockDto::from).toList(); + .stream().map(SectionDtos.BlockDto::from).toList(); } @PostMapping("/blocks") - public Dtos.BlockDto create(@RequestBody CreateBlockRequest req, @CurrentUser String userId) { + public SectionDtos.BlockDto create(@RequestBody CreateBlockRequest req, @CurrentUser String userId) { UUID examId = Access.id(req.exam_id()); access.requireExam(examId, userId); SectionBlock b = new SectionBlock(); @@ -53,11 +53,11 @@ public Dtos.BlockDto create(@RequestBody CreateBlockRequest req, @CurrentUser St b.setPosition(req.position() == null ? 0 : req.position()); if (req.content() != null) b.setContent(req.content()); if (req.kind() != null) b.setKind(req.kind()); - return Dtos.BlockDto.from(crud.addBlock(b)); + return SectionDtos.BlockDto.from(sectionService.addBlock(b)); } @PatchMapping("/blocks/{id}") - public Dtos.BlockDto patch(@PathVariable String id, @RequestBody Map body, + public SectionDtos.BlockDto patch(@PathVariable String id, @RequestBody Map body, @CurrentUser String userId) { SectionBlock b = load(id, userId); if (Patch.has(body, "content")) b.setContent(Patch.str(body.get("content"))); @@ -69,7 +69,7 @@ public Dtos.BlockDto patch(@PathVariable String id, @RequestBody Map list(@PathVariable String examId, @CurrentUser String userId) { + public List list(@PathVariable String examId, @CurrentUser String userId) { access.requireExam(Access.id(examId), userId); return sectionRepository.findByExamIdOrderByPositionAsc(Access.id(examId)) - .stream().map(Dtos.SectionDto::from).toList(); + .stream().map(SectionDtos.SectionDto::from).toList(); } @PostMapping("/sections") - public Dtos.SectionDto create(@RequestBody CreateSectionRequest req, @CurrentUser String userId) { + public SectionDtos.SectionDto create(@RequestBody CreateSectionRequest req, @CurrentUser String userId) { UUID examId = Access.id(req.exam_id()); access.requireExam(examId, userId); Section s = new Section(); s.setExamId(examId); s.setPosition(req.position() == null ? 0 : req.position()); if (req.name() != null) s.setName(req.name()); - return Dtos.SectionDto.from(crud.addSection(s)); + return SectionDtos.SectionDto.from(sectionService.addSection(s)); } @PatchMapping("/sections/{id}") - public Dtos.SectionDto patch(@PathVariable String id, @RequestBody java.util.Map body, + public SectionDtos.SectionDto patch(@PathVariable String id, @RequestBody java.util.Map body, @CurrentUser String userId) { Section s = load(id, userId); // Only user-editable fields. `confirmed_at` has dedicated endpoints @@ -65,7 +65,7 @@ public Dtos.SectionDto patch(@PathVariable String id, @RequestBody java.util.Map // and `solve_started_at` / `goals_started_at` are internal CAS locks. if (Patch.has(body, "name")) s.setName(Patch.str(body.get("name"))); if (Patch.has(body, "position")) s.setPosition(Patch.intVal(body.get("position"))); - return Dtos.SectionDto.from(sectionRepository.save(s)); + return SectionDtos.SectionDto.from(sectionRepository.save(s)); } /** @@ -76,15 +76,15 @@ public Dtos.SectionDto patch(@PathVariable String id, @RequestBody java.util.Map @DeleteMapping("/sections/{id}") public ResponseEntity delete(@PathVariable String id, @CurrentUser String userId) { Section s = load(id, userId); - crud.deleteSection(s); + sectionService.deleteSection(s); return ResponseEntity.noContent().build(); } @PostMapping("/sections/{id}/confirm") - public Dtos.SectionDto confirm(@PathVariable String id, @CurrentUser String userId) { + public SectionDtos.SectionDto confirm(@PathVariable String id, @CurrentUser String userId) { Section s = load(id, userId); s.setConfirmedAt(OffsetDateTime.now()); - Dtos.SectionDto dto = Dtos.SectionDto.from(sectionRepository.save(s)); + SectionDtos.SectionDto dto = SectionDtos.SectionDto.from(sectionRepository.save(s)); // Fire-and-forget LGH goal generation; must never fail the confirm. try { goalGeneration.dispatchGenerate(s.getExamId(), s.getId()); @@ -93,10 +93,10 @@ public Dtos.SectionDto confirm(@PathVariable String id, @CurrentUser String user } @PostMapping("/sections/{id}/unconfirm") - public Dtos.SectionDto unconfirm(@PathVariable String id, @CurrentUser String userId) { + public SectionDtos.SectionDto unconfirm(@PathVariable String id, @CurrentUser String userId) { Section s = load(id, userId); - crud.unconfirmSection(s); - return Dtos.SectionDto.from(sectionRepository.findById(s.getId()).orElse(s)); + sectionService.unconfirmSection(s); + return SectionDtos.SectionDto.from(sectionRepository.findById(s.getId()).orElse(s)); } /** Load a section and verify the caller owns its exam. */ diff --git a/apps/examlense/backend/src/main/java/app/section/SectionDtos.java b/apps/examlense/backend/src/main/java/app/section/SectionDtos.java new file mode 100644 index 0000000..b509b07 --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/section/SectionDtos.java @@ -0,0 +1,41 @@ +package app.section; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** Response DTOs for the section slice (sections, context/figure blocks, figures). */ +public final class SectionDtos { + + private SectionDtos() {} + + public record SectionDto( + UUID id, UUID exam_id, int position, String name, + OffsetDateTime confirmed_at, OffsetDateTime solve_started_at, + OffsetDateTime created_at, OffsetDateTime updated_at + ) { + public static SectionDto from(Section s) { + return new SectionDto(s.getId(), s.getExamId(), s.getPosition(), s.getName(), + s.getConfirmedAt(), s.getSolveStartedAt(), s.getCreatedAt(), s.getUpdatedAt()); + } + } + + public record BlockDto( + UUID id, UUID exam_id, UUID section_id, int position, String content, String kind, + OffsetDateTime created_at, OffsetDateTime updated_at + ) { + public static BlockDto from(SectionBlock b) { + return new BlockDto(b.getId(), b.getExamId(), b.getSectionId(), b.getPosition(), + b.getContent(), b.getKind(), b.getCreatedAt(), b.getUpdatedAt()); + } + } + + public record FigureDto( + UUID id, UUID block_id, String storage_path, String caption, int position, + String source, OffsetDateTime created_at + ) { + public static FigureDto from(SectionFigure f) { + return new FigureDto(f.getId(), f.getBlockId(), f.getStoragePath(), f.getCaption(), + f.getPosition(), f.getSource(), f.getCreatedAt()); + } + } +} diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/SectionFigure.java b/apps/examlense/backend/src/main/java/app/section/SectionFigure.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/SectionFigure.java rename to apps/examlense/backend/src/main/java/app/section/SectionFigure.java index d19bb07..789ecdf 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/SectionFigure.java +++ b/apps/examlense/backend/src/main/java/app/section/SectionFigure.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.section; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/SectionFigureRepository.java b/apps/examlense/backend/src/main/java/app/section/SectionFigureRepository.java similarity index 76% rename from apps/examlense/backend/src/main/java/app/persistence/repository/SectionFigureRepository.java rename to apps/examlense/backend/src/main/java/app/section/SectionFigureRepository.java index 8532204..87bb0a0 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/SectionFigureRepository.java +++ b/apps/examlense/backend/src/main/java/app/section/SectionFigureRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.section; -import app.persistence.entity.SectionFigure; import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/SectionRepository.java b/apps/examlense/backend/src/main/java/app/section/SectionRepository.java similarity index 97% rename from apps/examlense/backend/src/main/java/app/persistence/repository/SectionRepository.java rename to apps/examlense/backend/src/main/java/app/section/SectionRepository.java index 2decdd7..a40621d 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/SectionRepository.java +++ b/apps/examlense/backend/src/main/java/app/section/SectionRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.section; -import app.persistence.entity.Section; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; diff --git a/apps/examlense/backend/src/main/java/app/section/SectionService.java b/apps/examlense/backend/src/main/java/app/section/SectionService.java new file mode 100644 index 0000000..fd80dfe --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/section/SectionService.java @@ -0,0 +1,110 @@ +package app.section; + +import app.exam.Exam; +import app.exam.ExamRepository; +import app.lgh.TaskGoalGenerationService; +import app.sse.SseHub; +import app.task.Task; +import app.task.TaskAnswerRepository; +import app.task.TaskRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Multi-step section/block operations that must be atomic: position-shifting + * inserts (ports of the {@code shift_and_insert_*} DB functions), section + * unconfirm (which also clears the section's AI answers and detaches LGH goals), + * and section delete with full child cleanup. + */ +@Service +public class SectionService { + + private final ExamRepository exams; + private final SectionRepository sections; + private final TaskRepository tasks; + private final SectionBlockRepository blocks; + private final TaskAnswerRepository answers; + private final TaskGoalGenerationService goalGeneration; + private final SseHub sse; + + public SectionService(ExamRepository exams, SectionRepository sections, TaskRepository tasks, + SectionBlockRepository blocks, TaskAnswerRepository answers, + TaskGoalGenerationService goalGeneration, SseHub sse) { + this.exams = exams; + this.sections = sections; + this.tasks = tasks; + this.blocks = blocks; + this.answers = answers; + this.goalGeneration = goalGeneration; + this.sse = sse; + } + + /** Insert a section at {@code position}, shifting later sections down. */ + @Transactional + public Section addSection(Section section) { + sections.shiftSectionsFrom(section.getExamId(), section.getPosition()); + return sections.save(section); + } + + /** Insert a block at its position, shifting that section's blocks and tasks down. */ + @Transactional + public SectionBlock addBlock(SectionBlock block) { + blocks.shiftBlocksInSection(block.getSectionId(), block.getPosition()); + tasks.shiftTasksInSection(block.getExamId(), block.getSectionId(), block.getPosition()); + return blocks.save(block); + } + + /** + * Clear a section's confirmation, drop the AI answers for its tasks, and + * detach the LGH-derived learning goals (deleted in LGH async best-effort + * — the goals were generated for the now-retracted content). + */ + @Transactional + public void unconfirmSection(Section section) { + List taskIds = new ArrayList<>(); + List goalIds = new ArrayList<>(); + List sectionTasks = tasks.findBySectionIdOrderByPositionAsc(section.getId()); + for (Task t : sectionTasks) { + taskIds.add(t.getId()); + if (t.getLearningGoalIds() != null) { + goalIds.addAll(t.getLearningGoalIds()); + t.setLearningGoalIds(null); + } + } + if (!taskIds.isEmpty()) answers.deleteByTaskIdIn(taskIds); + tasks.saveAll(sectionTasks); + section.setConfirmedAt(null); + sections.save(section); + + if (!goalIds.isEmpty()) { + Exam exam = exams.findById(section.getExamId()).orElse(null); + if (exam != null && exam.getLghCourseId() != null) { + try { + goalGeneration.dispatchCleanup(exam.getLghCourseId(), goalIds); + } catch (RuntimeException ignored) {} + } + } + // Answers were dropped regardless of whether goals existed — always + // tell SSE subscribers the tasks changed. + sse.tasksUpdated(section.getExamId()); + } + + /** + * Delete a section with full cleanup, server-side: unconfirm first when + * needed (drops AI answers + detaches LGH goals), then remove its tasks + * and blocks along with the section — the frontend is not trusted to call + * the delete-by-section endpoints beforehand. + */ + @Transactional + public void deleteSection(Section section) { + if (section.getConfirmedAt() != null) { + unconfirmSection(section); + } + tasks.deleteByExamIdAndSectionId(section.getExamId(), section.getId()); + blocks.deleteByExamIdAndSectionId(section.getExamId(), section.getId()); + sections.delete(section); + } +} diff --git a/apps/examlense/backend/src/main/java/app/security/CurrentUser.java b/apps/examlense/backend/src/main/java/app/security/CurrentUser.java index 135adc5..fa8fcea 100644 --- a/apps/examlense/backend/src/main/java/app/security/CurrentUser.java +++ b/apps/examlense/backend/src/main/java/app/security/CurrentUser.java @@ -10,7 +10,7 @@ /** * Inject the authenticated principal directly into controller args. With the * static-token auth filter the principal is the single-user id String - * ({@link app.persistence.DefaultUser#ID}). + * ({@link app.shared.DefaultUser#ID}). * * Usage: * @GetMapping("/me") diff --git a/apps/examlense/backend/src/main/java/app/security/StaticTokenAuthFilter.java b/apps/examlense/backend/src/main/java/app/security/StaticTokenAuthFilter.java index 292dff1..23d92cd 100644 --- a/apps/examlense/backend/src/main/java/app/security/StaticTokenAuthFilter.java +++ b/apps/examlense/backend/src/main/java/app/security/StaticTokenAuthFilter.java @@ -1,6 +1,6 @@ package app.security; -import app.persistence.DefaultUser; +import app.shared.DefaultUser; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/apps/examlense/backend/src/main/java/app/api/Access.java b/apps/examlense/backend/src/main/java/app/shared/Access.java similarity index 93% rename from apps/examlense/backend/src/main/java/app/api/Access.java rename to apps/examlense/backend/src/main/java/app/shared/Access.java index 63b245b..88c041a 100644 --- a/apps/examlense/backend/src/main/java/app/api/Access.java +++ b/apps/examlense/backend/src/main/java/app/shared/Access.java @@ -1,10 +1,10 @@ -package app.api; +package app.shared; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionRepository; +import app.exam.Exam; +import app.section.Section; +import app.exam.ExamRepository; +import app.section.SectionRepository; import java.util.UUID; import java.util.function.Function; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/examlense/backend/src/main/java/app/persistence/DefaultUser.java b/apps/examlense/backend/src/main/java/app/shared/DefaultUser.java similarity index 95% rename from apps/examlense/backend/src/main/java/app/persistence/DefaultUser.java rename to apps/examlense/backend/src/main/java/app/shared/DefaultUser.java index 23a427c..f2caa35 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/DefaultUser.java +++ b/apps/examlense/backend/src/main/java/app/shared/DefaultUser.java @@ -1,4 +1,4 @@ -package app.persistence; +package app.shared; import java.util.UUID; diff --git a/apps/examlense/backend/src/main/java/app/api/Patch.java b/apps/examlense/backend/src/main/java/app/shared/Patch.java similarity index 84% rename from apps/examlense/backend/src/main/java/app/api/Patch.java rename to apps/examlense/backend/src/main/java/app/shared/Patch.java index ca57ac9..faffbeb 100644 --- a/apps/examlense/backend/src/main/java/app/api/Patch.java +++ b/apps/examlense/backend/src/main/java/app/shared/Patch.java @@ -1,7 +1,7 @@ -package app.api; +package app.shared; import app.error.ApiException; -import app.persistence.entity.TaskOption; +import app.task.TaskOption; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -14,19 +14,19 @@ * (so "key absent" is distinguishable from "key set to null"). Malformed * values surface as 400s with the standard error body, never as 500s. */ -final class Patch { +public final class Patch { private Patch() {} - static boolean has(Map m, String key) { + public static boolean has(Map m, String key) { return m.containsKey(key); } - static String str(Object v) { + public static String str(Object v) { return v == null ? null : v.toString(); } - static BigDecimal bigDecimal(Object v) { + public static BigDecimal bigDecimal(Object v) { if (v == null) return null; try { if (v instanceof Number n) return new BigDecimal(n.toString()); @@ -36,7 +36,7 @@ static BigDecimal bigDecimal(Object v) { } } - static Integer intVal(Object v) { + public static Integer intVal(Object v) { if (v == null) return null; if (v instanceof Number n) return n.intValue(); try { @@ -46,7 +46,7 @@ static Integer intVal(Object v) { } } - static Long longVal(Object v) { + public static Long longVal(Object v) { if (v == null) return null; if (v instanceof Number n) return n.longValue(); try { @@ -56,14 +56,14 @@ static Long longVal(Object v) { } } - static List longList(Object v) { + public static List longList(Object v) { if (!(v instanceof List list)) return null; List out = new ArrayList<>(); for (Object o : list) if (o instanceof Number n) out.add(n.longValue()); return out; } - static UUID uuid(Object v) { + public static UUID uuid(Object v) { if (v == null) return null; try { return UUID.fromString(v.toString()); @@ -72,7 +72,7 @@ static UUID uuid(Object v) { } } - static List options(Object v) { + public static List options(Object v) { if (!(v instanceof List list)) return null; List out = new ArrayList<>(); for (Object o : list) { diff --git a/apps/examlense/backend/src/main/java/app/solve/SolveCore.java b/apps/examlense/backend/src/main/java/app/solve/SolveCore.java index b0b6698..571134b 100644 --- a/apps/examlense/backend/src/main/java/app/solve/SolveCore.java +++ b/apps/examlense/backend/src/main/java/app/solve/SolveCore.java @@ -3,16 +3,16 @@ import app.ai.AiExceptions; import app.ai.AiProvider; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.entity.TaskOption; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskGradeRepository; +import app.exam.Exam; +import app.section.Section; +import app.section.SectionBlock; +import app.task.Task; +import app.task.TaskAnswer; +import app.task.TaskOption; +import app.section.SectionBlockRepository; +import app.section.SectionRepository; +import app.task.TaskAnswerRepository; +import app.grading.TaskGradeRepository; import app.prompts.Prompts; import app.sse.SseHub; import org.springframework.http.HttpStatus; diff --git a/apps/examlense/backend/src/main/java/app/solve/SolveExamService.java b/apps/examlense/backend/src/main/java/app/solve/SolveExamService.java index 406014a..5c2cad7 100644 --- a/apps/examlense/backend/src/main/java/app/solve/SolveExamService.java +++ b/apps/examlense/backend/src/main/java/app/solve/SolveExamService.java @@ -2,14 +2,14 @@ import app.ai.AiExceptions; import app.ai.SolverStrategies; -import app.api.Access; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskGradeRepository; -import app.persistence.repository.TaskRepository; +import app.shared.Access; +import app.exam.Exam; +import app.task.Task; +import app.task.TaskAnswer; +import app.exam.ExamRepository; +import app.task.TaskAnswerRepository; +import app.grading.TaskGradeRepository; +import app.task.TaskRepository; import app.sse.SseHub; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/apps/examlense/backend/src/main/java/app/solve/SolveSectionService.java b/apps/examlense/backend/src/main/java/app/solve/SolveSectionService.java index 8db7d7c..4bd2ccc 100644 --- a/apps/examlense/backend/src/main/java/app/solve/SolveSectionService.java +++ b/apps/examlense/backend/src/main/java/app/solve/SolveSectionService.java @@ -3,13 +3,13 @@ import app.ai.AiProvider; import app.ai.AiProviderFactory; import app.ai.SolverStrategies; -import app.api.Access; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskRepository; +import app.shared.Access; +import app.exam.Exam; +import app.section.Section; +import app.task.Task; +import app.task.TaskAnswer; +import app.section.SectionRepository; +import app.task.TaskRepository; import app.prompts.Prompts; import org.springframework.stereotype.Service; diff --git a/apps/examlense/backend/src/main/java/app/solve/SolveTaskService.java b/apps/examlense/backend/src/main/java/app/solve/SolveTaskService.java index bff761c..0843626 100644 --- a/apps/examlense/backend/src/main/java/app/solve/SolveTaskService.java +++ b/apps/examlense/backend/src/main/java/app/solve/SolveTaskService.java @@ -4,11 +4,11 @@ import app.ai.AiProvider; import app.ai.AiProviderFactory; import app.ai.SolverStrategies; -import app.api.Access; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.task.Task; +import app.task.TaskRepository; import app.prompts.Prompts; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; diff --git a/apps/examlense/backend/src/main/java/app/sse/SseController.java b/apps/examlense/backend/src/main/java/app/sse/SseController.java index 215ff8c..83f4b62 100644 --- a/apps/examlense/backend/src/main/java/app/sse/SseController.java +++ b/apps/examlense/backend/src/main/java/app/sse/SseController.java @@ -1,6 +1,6 @@ package app.sse; -import app.api.Access; +import app.shared.Access; import app.security.CurrentUser; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/apps/examlense/backend/src/main/java/app/api/FileController.java b/apps/examlense/backend/src/main/java/app/storage/FileController.java similarity index 95% rename from apps/examlense/backend/src/main/java/app/api/FileController.java rename to apps/examlense/backend/src/main/java/app/storage/FileController.java index 250d15c..a425539 100644 --- a/apps/examlense/backend/src/main/java/app/api/FileController.java +++ b/apps/examlense/backend/src/main/java/app/storage/FileController.java @@ -1,11 +1,10 @@ -package app.api; +package app.storage; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; +import app.exam.Exam; +import app.exam.ExamRepository; import app.security.CurrentUser; -import app.storage.SignedUrls; -import app.storage.StorageService; import java.io.IOException; import java.util.Map; import org.springframework.http.HttpStatus; diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/Task.java b/apps/examlense/backend/src/main/java/app/task/Task.java similarity index 99% rename from apps/examlense/backend/src/main/java/app/persistence/entity/Task.java rename to apps/examlense/backend/src/main/java/app/task/Task.java index fcdade2..feee5b6 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/Task.java +++ b/apps/examlense/backend/src/main/java/app/task/Task.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.task; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/TaskAnswer.java b/apps/examlense/backend/src/main/java/app/task/TaskAnswer.java similarity index 98% rename from apps/examlense/backend/src/main/java/app/persistence/entity/TaskAnswer.java rename to apps/examlense/backend/src/main/java/app/task/TaskAnswer.java index a5318a4..1849108 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/TaskAnswer.java +++ b/apps/examlense/backend/src/main/java/app/task/TaskAnswer.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.task; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/apps/examlense/backend/src/main/java/app/api/TaskAnswerController.java b/apps/examlense/backend/src/main/java/app/task/TaskAnswerController.java similarity index 79% rename from apps/examlense/backend/src/main/java/app/api/TaskAnswerController.java rename to apps/examlense/backend/src/main/java/app/task/TaskAnswerController.java index f8e33f9..ca1665e 100644 --- a/apps/examlense/backend/src/main/java/app/api/TaskAnswerController.java +++ b/apps/examlense/backend/src/main/java/app/task/TaskAnswerController.java @@ -1,6 +1,6 @@ -package app.api; +package app.task; +import app.shared.Access; -import app.persistence.repository.TaskAnswerRepository; import app.security.CurrentUser; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; @@ -22,9 +22,9 @@ public TaskAnswerController(TaskAnswerRepository answerRepository, Access access /** AI answers are written by the solve services; the frontend only reads them. */ @GetMapping("/exams/{examId}/answers") - public List list(@PathVariable String examId, @CurrentUser String userId) { + public List list(@PathVariable String examId, @CurrentUser String userId) { access.requireExam(Access.id(examId), userId); return answerRepository.findByExamId(Access.id(examId)) - .stream().map(Dtos.AnswerDto::from).toList(); + .stream().map(TaskDtos.AnswerDto::from).toList(); } } diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/TaskAnswerRepository.java b/apps/examlense/backend/src/main/java/app/task/TaskAnswerRepository.java similarity index 92% rename from apps/examlense/backend/src/main/java/app/persistence/repository/TaskAnswerRepository.java rename to apps/examlense/backend/src/main/java/app/task/TaskAnswerRepository.java index 017b227..2082818 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/TaskAnswerRepository.java +++ b/apps/examlense/backend/src/main/java/app/task/TaskAnswerRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.task; -import app.persistence.entity.TaskAnswer; import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/examlense/backend/src/main/java/app/api/TaskController.java b/apps/examlense/backend/src/main/java/app/task/TaskController.java similarity index 86% rename from apps/examlense/backend/src/main/java/app/api/TaskController.java rename to apps/examlense/backend/src/main/java/app/task/TaskController.java index 23b8f83..044705e 100644 --- a/apps/examlense/backend/src/main/java/app/api/TaskController.java +++ b/apps/examlense/backend/src/main/java/app/task/TaskController.java @@ -1,9 +1,8 @@ -package app.api; +package app.task; +import app.shared.Patch; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Task; -import app.persistence.entity.TaskOption; -import app.persistence.repository.TaskRepository; import app.security.CurrentUser; import java.math.BigDecimal; import java.util.List; @@ -33,23 +32,23 @@ public record CreateTaskRequest( private final TaskRepository taskRepository; private final Access access; - private final CrudService crud; + private final TaskService taskService; - public TaskController(TaskRepository taskRepository, Access access, CrudService crud) { + public TaskController(TaskRepository taskRepository, Access access, TaskService taskService) { this.taskRepository = taskRepository; this.access = access; - this.crud = crud; + this.taskService = taskService; } @GetMapping("/exams/{examId}/tasks") - public List list(@PathVariable String examId, @CurrentUser String userId) { + public List list(@PathVariable String examId, @CurrentUser String userId) { access.requireExam(Access.id(examId), userId); return taskRepository.findByExamIdOrderByPositionAsc(Access.id(examId)) - .stream().map(Dtos.TaskDto::from).toList(); + .stream().map(TaskDtos.TaskDto::from).toList(); } @PostMapping("/tasks") - public Dtos.TaskDto create(@RequestBody CreateTaskRequest req, @CurrentUser String userId) { + public TaskDtos.TaskDto create(@RequestBody CreateTaskRequest req, @CurrentUser String userId) { UUID examId = Access.id(req.exam_id()); access.requireExam(examId, userId); Task t = new Task(); @@ -62,11 +61,11 @@ public Dtos.TaskDto create(@RequestBody CreateTaskRequest req, @CurrentUser Stri t.setOptions(req.options()); t.setReferenceAnswer(req.reference_answer()); t.setPoints(req.points()); - return Dtos.TaskDto.from(crud.addTask(t)); + return TaskDtos.TaskDto.from(taskService.addTask(t)); } @PatchMapping("/tasks/{id}") - public Dtos.TaskDto patch(@PathVariable String id, @RequestBody Map body, + public TaskDtos.TaskDto patch(@PathVariable String id, @RequestBody Map body, @CurrentUser String userId) { Task t = load(id, userId); if (Patch.has(body, "position")) t.setPosition(Patch.intVal(body.get("position"))); @@ -84,7 +83,7 @@ public Dtos.TaskDto patch(@PathVariable String id, @RequestBody Map options, String reference_answer, BigDecimal points, + String parse_confidence, List learning_goal_ids, + OffsetDateTime created_at, OffsetDateTime updated_at + ) { + public static TaskDto from(Task t) { + return new TaskDto(t.getId(), t.getExamId(), t.getSectionId(), t.getPosition(), + t.getSection(), t.getType(), t.getPrompt(), t.getOptions(), t.getReferenceAnswer(), + t.getPoints(), t.getParseConfidence(), t.getLearningGoalIds(), + t.getCreatedAt(), t.getUpdatedAt()); + } + } + + public record AnswerDto( + UUID id, UUID task_id, UUID exam_id, List selected_option_ids, + String answer_text, String reasoning, String provider, String model, OffsetDateTime created_at + ) { + public static AnswerDto from(TaskAnswer a) { + return new AnswerDto(a.getId(), a.getTaskId(), a.getExamId(), a.getSelectedOptionIds(), + a.getAnswerText(), a.getReasoning(), a.getProvider(), a.getModel(), a.getCreatedAt()); + } + } +} diff --git a/apps/examlense/backend/src/main/java/app/persistence/entity/TaskOption.java b/apps/examlense/backend/src/main/java/app/task/TaskOption.java similarity index 91% rename from apps/examlense/backend/src/main/java/app/persistence/entity/TaskOption.java rename to apps/examlense/backend/src/main/java/app/task/TaskOption.java index 7764a34..e362cb5 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/entity/TaskOption.java +++ b/apps/examlense/backend/src/main/java/app/task/TaskOption.java @@ -1,4 +1,4 @@ -package app.persistence.entity; +package app.task; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/apps/examlense/backend/src/main/java/app/persistence/repository/TaskRepository.java b/apps/examlense/backend/src/main/java/app/task/TaskRepository.java similarity index 96% rename from apps/examlense/backend/src/main/java/app/persistence/repository/TaskRepository.java rename to apps/examlense/backend/src/main/java/app/task/TaskRepository.java index ff82ead..f361718 100644 --- a/apps/examlense/backend/src/main/java/app/persistence/repository/TaskRepository.java +++ b/apps/examlense/backend/src/main/java/app/task/TaskRepository.java @@ -1,6 +1,5 @@ -package app.persistence.repository; +package app.task; -import app.persistence.entity.Task; import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/examlense/backend/src/main/java/app/task/TaskService.java b/apps/examlense/backend/src/main/java/app/task/TaskService.java new file mode 100644 index 0000000..eb0b9c4 --- /dev/null +++ b/apps/examlense/backend/src/main/java/app/task/TaskService.java @@ -0,0 +1,34 @@ +package app.task; + +import app.section.SectionBlockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Multi-step task operations that must be atomic: the position-shifting insert + * (port of the {@code shift_and_insert_task} DB function), which shifts both the + * same-bucket tasks and the section's blocks down to open a slot. + */ +@Service +public class TaskService { + + private final TaskRepository tasks; + private final SectionBlockRepository blocks; + + public TaskService(TaskRepository tasks, SectionBlockRepository blocks) { + this.tasks = tasks; + this.blocks = blocks; + } + + /** Insert a task at its position, shifting same-bucket tasks (and section blocks) down. */ + @Transactional + public Task addTask(Task task) { + if (task.getSectionId() != null) { + tasks.shiftTasksInSection(task.getExamId(), task.getSectionId(), task.getPosition()); + blocks.shiftBlocksInSection(task.getSectionId(), task.getPosition()); + } else { + tasks.shiftTasksNullSection(task.getExamId(), task.getPosition()); + } + return tasks.save(task); + } +} diff --git a/apps/examlense/backend/src/test/java/app/api/OwnershipIsolationIT.java b/apps/examlense/backend/src/test/java/app/OwnershipIsolationIT.java similarity index 96% rename from apps/examlense/backend/src/test/java/app/api/OwnershipIsolationIT.java rename to apps/examlense/backend/src/test/java/app/OwnershipIsolationIT.java index a6bb34f..633244a 100644 --- a/apps/examlense/backend/src/test/java/app/api/OwnershipIsolationIT.java +++ b/apps/examlense/backend/src/test/java/app/OwnershipIsolationIT.java @@ -1,9 +1,8 @@ -package app.api; +package app; -import app.AbstractIntegrationTest; -import app.persistence.DefaultUser; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; +import app.shared.DefaultUser; +import app.exam.Exam; +import app.exam.ExamRepository; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/apps/examlense/backend/src/test/java/app/persistence/SchemaIntegrityIT.java b/apps/examlense/backend/src/test/java/app/SchemaIntegrityIT.java similarity index 88% rename from apps/examlense/backend/src/test/java/app/persistence/SchemaIntegrityIT.java rename to apps/examlense/backend/src/test/java/app/SchemaIntegrityIT.java index cb19fc0..7b5b6cb 100644 --- a/apps/examlense/backend/src/test/java/app/persistence/SchemaIntegrityIT.java +++ b/apps/examlense/backend/src/test/java/app/SchemaIntegrityIT.java @@ -1,16 +1,15 @@ -package app.persistence; - -import app.AbstractIntegrationTest; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskRepository; +package app; + +import app.exam.Exam; +import app.section.Section; +import app.section.SectionBlock; +import app.task.Task; +import app.task.TaskAnswer; +import app.exam.ExamRepository; +import app.section.SectionBlockRepository; +import app.section.SectionRepository; +import app.task.TaskAnswerRepository; +import app.task.TaskRepository; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/apps/examlense/backend/src/test/java/app/api/CrudServiceIT.java b/apps/examlense/backend/src/test/java/app/exam/CrudOperationsIT.java similarity index 85% rename from apps/examlense/backend/src/test/java/app/api/CrudServiceIT.java rename to apps/examlense/backend/src/test/java/app/exam/CrudOperationsIT.java index 19bc073..c4f5d18 100644 --- a/apps/examlense/backend/src/test/java/app/api/CrudServiceIT.java +++ b/apps/examlense/backend/src/test/java/app/exam/CrudOperationsIT.java @@ -1,19 +1,17 @@ -package app.api; +package app.exam; import app.AbstractIntegrationTest; -import app.persistence.DefaultUser; -import app.persistence.entity.Exam; -import app.persistence.entity.Section; -import app.persistence.entity.SectionBlock; -import app.persistence.entity.SectionFigure; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionFigureRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskRepository; +import app.shared.DefaultUser; +import app.section.Section; +import app.section.SectionBlock; +import app.section.SectionFigure; +import app.task.Task; +import app.task.TaskAnswer; +import app.section.SectionBlockRepository; +import app.section.SectionFigureRepository; +import app.section.SectionRepository; +import app.task.TaskAnswerRepository; +import app.task.TaskRepository; import app.storage.StorageService; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; @@ -25,15 +23,17 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * CrudService owns the multi-step operations that must stay atomic and correct: - * deep exam duplication and section unconfirm (which also clears AI answers and - * detaches learning goals). Run against a real DB since they touch several tables. + * ExamService and SectionService own the multi-step operations that must stay + * atomic and correct: deep exam duplication and section unconfirm (which also + * clears AI answers and detaches learning goals). Run against a real DB since + * they touch several tables. */ -class CrudServiceIT extends AbstractIntegrationTest { +class CrudOperationsIT extends AbstractIntegrationTest { private static final String FIGURE_BUCKET = "exam-figures"; - @Autowired CrudService crud; + @Autowired ExamService examService; + @Autowired app.section.SectionService sectionService; @Autowired ExamRepository exams; @Autowired SectionRepository sections; @Autowired TaskRepository tasks; @@ -105,7 +105,7 @@ void duplicateExamDeepCopiesContentAsAFreshDraft() { byte[] imageBytes = "fake-png-bytes".getBytes(StandardCharsets.UTF_8); SectionFigure srcFig = addFigure(src, srcSection, imageBytes); - Exam copy = crud.duplicateExam(src, DefaultUser.ID.toString()); + Exam copy = examService.duplicateExam(src, DefaultUser.ID.toString()); assertThat(copy.getId()).isNotEqualTo(src.getId()); assertThat(copy.getTitle()).isEqualTo("Original (Copy)"); @@ -146,7 +146,7 @@ void duplicateExamSkipsFiguresWhoseSourceBytesAreMissing() { // Figure row exists but its image object was never stored. addFigure(src, srcSection, null); - Exam copy = crud.duplicateExam(src, DefaultUser.ID.toString()); + Exam copy = examService.duplicateExam(src, DefaultUser.ID.toString()); // The figure block is still copied... SectionBlock copiedFigBlock = blocks.findByExamIdOrderByPositionAsc(copy.getId()).stream() @@ -170,7 +170,7 @@ void unconfirmSectionClearsConfirmationDropsAnswersAndDetachesGoals() { answer.setModel("gpt-5.5"); answers.save(answer); - crud.unconfirmSection(section); + sectionService.unconfirmSection(section); Section reloaded = sections.findById(section.getId()).orElseThrow(); assertThat(reloaded.getConfirmedAt()).isNull(); diff --git a/apps/examlense/backend/src/test/java/app/api/ExamProgressServiceIT.java b/apps/examlense/backend/src/test/java/app/exam/ExamProgressServiceIT.java similarity index 91% rename from apps/examlense/backend/src/test/java/app/api/ExamProgressServiceIT.java rename to apps/examlense/backend/src/test/java/app/exam/ExamProgressServiceIT.java index e9ca1bb..ebf2494 100644 --- a/apps/examlense/backend/src/test/java/app/api/ExamProgressServiceIT.java +++ b/apps/examlense/backend/src/test/java/app/exam/ExamProgressServiceIT.java @@ -1,16 +1,14 @@ -package app.api; +package app.exam; import app.AbstractIntegrationTest; -import app.persistence.DefaultUser; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.entity.TaskAnswer; -import app.persistence.entity.TaskGrade; -import app.persistence.entity.TaskOption; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskGradeRepository; -import app.persistence.repository.TaskRepository; +import app.shared.DefaultUser; +import app.task.Task; +import app.task.TaskAnswer; +import app.grading.TaskGrade; +import app.task.TaskOption; +import app.task.TaskAnswerRepository; +import app.grading.TaskGradeRepository; +import app.task.TaskRepository; import java.math.BigDecimal; import java.util.List; import java.util.UUID; diff --git a/apps/examlense/backend/src/test/java/app/lgh/TaskGoalGenerationServiceTest.java b/apps/examlense/backend/src/test/java/app/lgh/TaskGoalGenerationServiceTest.java index a452253..584e66e 100644 --- a/apps/examlense/backend/src/test/java/app/lgh/TaskGoalGenerationServiceTest.java +++ b/apps/examlense/backend/src/test/java/app/lgh/TaskGoalGenerationServiceTest.java @@ -1,10 +1,10 @@ package app.lgh; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionBlockRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.exam.ExamRepository; +import app.section.SectionBlockRepository; +import app.section.SectionRepository; +import app.task.TaskRepository; import app.sse.SseHub; import java.util.List; import java.util.Optional; diff --git a/apps/examlense/backend/src/test/java/app/parse/ParseMetricsServiceTest.java b/apps/examlense/backend/src/test/java/app/parse/ParseMetricsServiceTest.java index 4711396..1a51ed8 100644 --- a/apps/examlense/backend/src/test/java/app/parse/ParseMetricsServiceTest.java +++ b/apps/examlense/backend/src/test/java/app/parse/ParseMetricsServiceTest.java @@ -2,8 +2,6 @@ import app.parse.ParseMetricsService.Metrics; import app.parse.ParseMetricsService.ModelStat; -import app.persistence.entity.ParseMetric; -import app.persistence.repository.ParseMetricRepository; import org.junit.jupiter.api.Test; import java.util.List; diff --git a/apps/examlense/backend/src/test/java/app/parse/ParsedExamNormalizationTest.java b/apps/examlense/backend/src/test/java/app/parse/ParsedExamNormalizationTest.java index 4002505..95fbae9 100644 --- a/apps/examlense/backend/src/test/java/app/parse/ParsedExamNormalizationTest.java +++ b/apps/examlense/backend/src/test/java/app/parse/ParsedExamNormalizationTest.java @@ -1,6 +1,6 @@ package app.parse; -import app.persistence.entity.TaskOption; +import app.task.TaskOption; import org.junit.jupiter.api.Test; import java.util.ArrayList; diff --git a/apps/examlense/backend/src/test/java/app/security/StaticTokenAuthFilterTest.java b/apps/examlense/backend/src/test/java/app/security/StaticTokenAuthFilterTest.java index f78d424..990d179 100644 --- a/apps/examlense/backend/src/test/java/app/security/StaticTokenAuthFilterTest.java +++ b/apps/examlense/backend/src/test/java/app/security/StaticTokenAuthFilterTest.java @@ -1,6 +1,6 @@ package app.security; -import app.persistence.DefaultUser; +import app.shared.DefaultUser; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; diff --git a/apps/examlense/backend/src/test/java/app/api/AccessTest.java b/apps/examlense/backend/src/test/java/app/shared/AccessTest.java similarity index 94% rename from apps/examlense/backend/src/test/java/app/api/AccessTest.java rename to apps/examlense/backend/src/test/java/app/shared/AccessTest.java index 148ff2d..1cc9764 100644 --- a/apps/examlense/backend/src/test/java/app/api/AccessTest.java +++ b/apps/examlense/backend/src/test/java/app/shared/AccessTest.java @@ -1,9 +1,9 @@ -package app.api; +package app.shared; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionRepository; +import app.exam.Exam; +import app.exam.ExamRepository; +import app.section.SectionRepository; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; diff --git a/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceAsyncTest.java b/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceAsyncTest.java index ccf7ef6..71b08ab 100644 --- a/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceAsyncTest.java +++ b/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceAsyncTest.java @@ -1,13 +1,13 @@ package app.solve; -import app.api.Access; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.SectionRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskGradeRepository; -import app.persistence.repository.TaskRepository; +import app.shared.Access; +import app.exam.Exam; +import app.task.Task; +import app.exam.ExamRepository; +import app.section.SectionRepository; +import app.task.TaskAnswerRepository; +import app.grading.TaskGradeRepository; +import app.task.TaskRepository; import app.sse.SseHub; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; diff --git a/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceTest.java b/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceTest.java index 7f8ac8c..e7a809c 100644 --- a/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceTest.java +++ b/apps/examlense/backend/src/test/java/app/solve/SolveExamServiceTest.java @@ -1,13 +1,13 @@ package app.solve; -import app.api.Access; +import app.shared.Access; import app.error.ApiException; -import app.persistence.entity.Exam; -import app.persistence.entity.Task; -import app.persistence.repository.ExamRepository; -import app.persistence.repository.TaskAnswerRepository; -import app.persistence.repository.TaskGradeRepository; -import app.persistence.repository.TaskRepository; +import app.exam.Exam; +import app.task.Task; +import app.exam.ExamRepository; +import app.task.TaskAnswerRepository; +import app.grading.TaskGradeRepository; +import app.task.TaskRepository; import app.sse.SseHub; import java.util.List; import java.util.UUID; diff --git a/apps/examlense/backend/src/test/java/app/api/FileControllerIT.java b/apps/examlense/backend/src/test/java/app/storage/FileControllerIT.java similarity index 95% rename from apps/examlense/backend/src/test/java/app/api/FileControllerIT.java rename to apps/examlense/backend/src/test/java/app/storage/FileControllerIT.java index 1cee20f..bfb9935 100644 --- a/apps/examlense/backend/src/test/java/app/api/FileControllerIT.java +++ b/apps/examlense/backend/src/test/java/app/storage/FileControllerIT.java @@ -1,10 +1,9 @@ -package app.api; +package app.storage; import app.AbstractIntegrationTest; -import app.persistence.DefaultUser; -import app.persistence.entity.Exam; -import app.persistence.repository.ExamRepository; -import app.storage.SignedUrls; +import app.shared.DefaultUser; +import app.exam.Exam; +import app.exam.ExamRepository; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired;