Compare commits

...
Author SHA1 Message Date
sechmachine 432389220f test(account): cover missing picker business date 2026-08-15 14:36:11 +07:00
sechmachine e70159a81b feat(account): add eligible Intern picker query 2026-08-15 14:23:45 +07:00
sechmachine 8be1b754e1 docs: add comprehensive implementation and design guidelines 2026-08-15 13:04:50 +07:00
sechmachine bca30e6e4b docs(platform): pin bootstrap startup evidence 2026-08-15 12:47:47 +07:00
sechmachine 531c607852 fix(platform): repair development bootstrap flow 2026-08-15 12:47:25 +07:00
sechmachine b764707716 merge(iteration-1): adopt reporting lombok conventions 2026-08-15 11:51:55 +07:00
sechmachine 6725f6a97d merge(iteration-1): adopt attendance lombok conventions 2026-08-15 11:51:46 +07:00
sechmachine daa97aef71 merge(iteration-1): adopt task lombok conventions 2026-08-15 11:51:36 +07:00
sechmachine 73b57360a6 merge(iteration-1): adopt project lombok conventions 2026-08-15 11:51:26 +07:00
sechmachine f93ced67b2 merge(iteration-1): adopt platform lombok conventions 2026-08-15 11:51:13 +07:00
sechmachine c949973282 docs(tests): pin platform Lombok evidence 2026-08-15 11:49:58 +07:00
sechmachine 5ddbd75f07 test(attendance): guard component method surfaces 2026-08-15 11:49:53 +07:00
sechmachine 587127100d test(project): remove brittle Lombok source audit 2026-08-15 11:46:01 +07:00
sechmachine 9023062477 test(attendance): verify compiled lombok contracts 2026-08-15 11:45:08 +07:00
sechmachine 469d7e274b docs(test): complete Task constructor inspection evidence 2026-08-15 11:40:26 +07:00
sechmachine 41448903aa refactor(platform): target Lombok boilerplate 2026-08-15 11:39:53 +07:00
sechmachine 8500cb6eb7 docs(test): correct Reporting Lombok scenario scope 2026-08-15 11:39:36 +07:00
sechmachine 4b1e5721e4 docs(project): pin Lombok cleanup evidence 2026-08-15 11:37:29 +07:00
sechmachine e5639c1461 refactor(project): replace mechanical constructors with Lombok 2026-08-15 11:37:02 +07:00
sechmachine baa2752466 docs(test): record Reporting structure gate 2026-08-15 11:35:04 +07:00
sechmachine 382fa40101 docs(test): pin Task Lombok evidence 2026-08-15 11:34:55 +07:00
sechmachine 7c1a26d77f refactor(task): replace mechanical boilerplate with Lombok 2026-08-15 11:34:37 +07:00
sechmachine abc5208d67 docs(attendance): finalize lombok evidence 2026-08-15 11:34:34 +07:00
sechmachine 82ad8202fd refactor(attendance): target lombok boilerplate 2026-08-15 11:34:11 +07:00
sechmachine 1eb995500c docs(test): pin Reporting Lombok evidence 2026-08-15 11:33:28 +07:00
sechmachine e2b206c27e refactor(reporting): use Lombok injection constructors 2026-08-15 11:32:59 +07:00
75 changed files with 2807 additions and 701 deletions
+413
View File
@@ -0,0 +1,413 @@
# Lab Timesheet — Three-Iteration Delivery Plan
**Artifact purpose:** Local agent coordination and progress tracking
**Implementation branches:** `work/platform`, `work/projects`, `work/tasks`, `work/attendance`, `work/reports-ui`
**Requirements authority:** `labtimesheet-docs-hub/requirements-specification.md`
**SRS:** `labtimesheet-docs-hub/software-requirements-specification.md`
**Initial status:** Planning complete; implementation remains subject to requirements approval
This file divides the approved product scope across three iterations and five persistent work branches. It is a coordination artifact, not an alternative requirements source. When this plan and a numbered requirement disagree, the numbered requirement wins.
## 1. Progress rules
Use these exact status values:
| Status | Meaning |
|---|---|
| `TODO` | No implementation work has started. |
| `IN_PROGRESS` | One named owner is actively working on the item. |
| `BLOCKED` | Work cannot continue; the tracker must name the evidence and required decision/dependency. |
| `DONE` | Required RED/GREEN evidence exists, affected tests pass, and the integrated behavior satisfies the requirement. |
Before editing production code, an agent shall:
1. Read the applicable numbered requirements and acceptance scenarios.
2. Claim one bounded tracker item by setting its status to `IN_PROGRESS` and recording owner/date.
3. Identify the test level and evidence file that will protect the behavior.
4. Write and run the failing test before production code.
5. Confirm the test fails because the required behavior is missing, not because the test or environment is broken.
When completing an item, the agent shall record:
- the exact RED command and expected failure;
- the exact GREEN and affected-suite commands;
- the evidence Markdown path under `docs/tests/`;
- the implementation commit or final local commit SHA;
- any remaining limitation that is explicitly allowed by the requirements.
No item becomes `DONE` based only on compilation, an isolated happy path, screenshots, or a verbal claim.
## 2. Branch ownership and conflict boundaries
| Branch | Sole or primary ownership |
|---|---|
| `work/platform` | Maven/application baseline, feature-package foundation, Flyway migration files, account/security/bootstrap, internship lifecycle, integration credential lifecycle, notification delivery infrastructure, Docker, and CI. |
| `work/projects` | Projects, membership intervals, invitations, membership-exit requests, leadership terms, Project lifecycle, Project-scoped authorization, member-removal orchestration, and Project completion. |
| `work/tasks` | Tasks, generic creator/assignment actors, member self-Task rules, comments, work logs, fixed status transitions, assignment/reassignment, Task soft deletion, and Project Task-progress calculations. |
| `work/attendance` | Attendance-policy versions, configured workdays, global calendar, HolidayAPI import interpretation, attendance, corrections, leave, deadline schedulers, and attendance/compliance metrics. |
| `work/reports-ui` | Shared Thymeleaf shell/fragments, Tailwind tokens/assets, dashboards, invitation/exit-request screens, shared report datasets, Chart.js presentation, XLSX/PDF exports, and cross-product UI/accessibility consistency. |
Conflict-prevention rules:
- `LabtimesheetApplication` shall remain in `com.lab.labtimesheet`, shared wiring in `config`, and business code in `feature.account`, `feature.integration`, `feature.project`, `feature.task`, `feature.attendance`, `feature.notification`, or `feature.reporting`. Each feature repeats only the controller/model/model.dto/model.entity/repository/service/exception layers it needs, and tests mirror that feature/layer shape.
- Cross-feature code may call another feature's service contract and DTOs but shall not import that feature's repository or JPA entity. Do not create empty `utils`, `common`, or `core` packages.
- Business persistence shall use Spring Data JPA repositories. Direct SQL is limited to Flyway migrations and schema/catalog verification; services shall not use `JdbcTemplate` or embed SQL.
- `work/platform` owns `src/main/resources/db/migration/**`, Maven/dependency configuration, Compose, container build files, and CI workflow files. Other branches request schema changes instead of independently allocating migration versions.
- `work/reports-ui` owns shared templates/fragments, shared design tokens, and general UI assets. Each domain branch owns its module-specific controllers and pages while consuming those shared fragments.
- `work/tasks` exposes focused Task query/transfer operations required by Project workflows. `work/projects` owns the transaction that removes a member or completes a Project.
- `work/platform` owns HolidayAPI credential storage and the tested HTTP client. `work/attendance` owns preview interpretation, selection, deduplication, import, and day-off effects.
- Attendance time and Task work time remain separate. No branch may make one mutate or prove the other.
- Do not introduce a generic workflow engine, generic event-sourcing layer, multi-assignee Task model, Project-level day-off model, or speculative cross-module abstraction.
## 3. Iteration overview
| Iteration | Theme | Required demonstration | Status | Integration commit |
|---|---|---|---|---|
| 1 | Working vertical slice | Bootstrap users, create/activate Project, assign/change a Task, and check in/out with role-correct UI. | `DONE` | `b9b150ff8ca9333e3b46d77537ec91875a970d57` |
| 2 | Complete business workflows | Policy/calendar changes, leave/corrections, leadership/member transfer, work logs, notifications, and full HTML workflows. | `TODO` | — |
| 3 | Hardening and delivery | Historical/concurrency proof, production security, HTML/XLSX/PDF parity, accessibility, containers, and CI publication boundary. | `TODO` | — |
## 4. Iteration 1 — Working vertical slice
### 4.1 `work/platform`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I1-PLAT-01 | Establish the Maven/Spring Boot modular baseline and root/config/feature package boundaries. | Context/load test; root-package and package-by-feature/layer check; no cross-feature repository/entity access or direct-SQL business services. | `DONE` | platform_agent / 2026-08-15 | Approved Platform head `692b23e9b9891d360882671d8247965b44920b2f`; integrated architecture/full-suite gates passed. |
| I1-PLAT-02 | Promote the reviewed 23-table/56-foreign-key PostgreSQL baseline into the platform-owned initial Flyway migration after explicit approval. | Fresh PostgreSQL 18.4 migration replay, catalog assertions, and invitation/exit same-Project constraints. | `DONE` | platform_agent / 2026-08-15 | Fresh local replay produced exactly 23 application tables and 56 foreign keys at the integrated head. |
| I1-PLAT-03 | Configure PostgreSQL Testcontainers and shared test-only encryption/clock facilities. | Affected integration tests require no developer database or SMTP. | `DONE` | platform_agent / 2026-08-15 | Taskmaster full PostgreSQL 18.4 Testcontainers suite passed 197/197. |
| I1-PLAT-04 | Implement atomic first-Admin bootstrap and permanent bootstrap closure. | Concurrent submissions create exactly one first Admin; restart keeps bootstrap closed. | `DONE` | platform_agent / 2026-08-15 | Bootstrap/restart/concurrency evidence approved at `692b23e9b9891d360882671d8247965b44920b2f`; fresh local bootstrap passed. |
| I1-PLAT-05 | Implement initial SMTP draft/test/active path sufficient for Mailpit onboarding. | Failed test cannot activate; tested revision supports delivery. | `DONE` | platform_agent / 2026-08-15 | Real local Mailpit draft, test delivery, activation, active-state, and health checks passed at the integrated head. |
| I1-PLAT-06 | Create Mentor/Intern accounts, deliver activation, set first password, and authenticate/logout. | SMTP gate, single-use token, expiry, normalized email, role/state access. | `DONE` | platform_agent / 2026-08-15 | Activation/authentication evidence and independent review approved at `692b23e9b9891d360882671d8247965b44920b2f`. |
| I1-PLAT-07 | Provide development Compose with PostgreSQL and Mailpit plus initial Gitea verification workflow. | Fresh developer start and branch/main verification. | `DEFERRED` | taskmaster / 2026-08-15 | Explicitly excluded from this exit gate; application containerization, Compose, and CI remain future work. |
### 4.2 `work/projects`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I1-PRJ-01 | Atomically create a Mentor-owned `PLANNED` Project, eligible initial Leader membership, and first leadership term. | No committed Project is empty or leaderless; non-owner, ineligible Leader, and guessed-ID access are denied. | `DONE` | projects_agent / 2026-08-15 | Approved Project head `baa0695c60153bc997ebf8b11adcfbdd2cbc1962`; Project suite and review passed. |
| I1-PRJ-02 | Directly add eligible Interns through owning-Mentor-controlled interval memberships. | Multiple concurrent Projects per Intern; duplicate active membership rejected; no acceptance step for direct add. | `DONE` | projects_agent / 2026-08-15 | Membership/eligibility/IDOR evidence approved at `baa0695c60153bc997ebf8b11adcfbdd2cbc1962`. |
| I1-PRJ-03 | Appoint and change one current Leader from active same-Project members. | One current Leader; non-member/ineligible selection rejected. | `DONE` | projects_agent / 2026-08-15 | Leadership-term invariants and completed-history behavior approved at `baa0695c60153bc997ebf8b11adcfbdd2cbc1962`. |
| I1-PRJ-04 | Activate a Project when initial member, Leader, date, and assignee guards pass. | Missing Leader/member or invalid assignee blocks activation. | `DONE` | projects_agent / 2026-08-15 | Task-bound activation guard approved at `baa0695c60153bc997ebf8b11adcfbdd2cbc1962`. |
| I1-PRJ-05 | Provide Project list/detail/member/leadership pages with owning-Mentor and member visibility. | MockMvc authorization plus direct-ID denial. | `DONE` | projects_agent / 2026-08-15 | Authorized pages, retained errors, and former-member history approved at `baa0695c60153bc997ebf8b11adcfbdd2cbc1962`. |
### 4.3 `work/attendance`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I1-ATT-01 | Resolve the seeded attendance policy, timezone, configured workdays, schedule, and separate check-in/checkout grace boundaries. | Both grace defaults are 30; values are 0720; checkout cutoff must stay before local midnight. | `DONE` | attendance_agent / 2026-08-15 | Approved Attendance head `01b8095e9459417e2cf5bd1079c796d4f01ec549`; fresh seed verified both grace values at 30. |
| I1-ATT-02 | Manage manual future global calendar events and day-off decisions. | Admin-only mutation; past-event immutability; workday/day-off distinction. | `DONE` | attendance_agent / 2026-08-15 | Calendar authorization and day-off evidence approved at `01b8095e9459417e2cf5bd1079c796d4f01ec549`. |
| I1-ATT-03 | Check in once on an eligible day using server time and the effective policy. | Off-day, approved-leave, duplicate, lifecycle rejection, and inclusive 09:00 check-in-grace boundary. | `DONE` | attendance_agent / 2026-08-15 | Boundary, eligibility, leave-day, and real duplicate-race evidence approved at `01b8095e9459417e2cf5bd1079c796d4f01ec549`. |
| I1-ATT-04 | Check out once through the attached-policy checkout cutoff and derive basic daily classification. | Default 16:00 succeeds; first later instant and zero-grace late attempt fail; no checkout becomes only `MISSING_CHECKOUT` with raw checkout unchanged. | `DONE` | attendance_agent / 2026-08-15 | Historical-policy cutoff, eligibility recheck, and missing-checkout-only evidence approved at `01b8095e9459417e2cf5bd1079c796d4f01ec549`. |
| I1-ATT-05 | Provide own-attendance history and authorized Mentor/Admin inspection. | Own/global-view authorization and historical applied-policy display. | `DONE` | attendance_agent / 2026-08-15 | Policy-local history, all violations, and role authorization approved at `01b8095e9459417e2cf5bd1079c796d4f01ec549`. |
### 4.4 `work/tasks`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I1-TSK-01 | Create one-assignee Tasks in `PLANNED` or `ACTIVE`: any active member for self, current Leader for any active same-Project member. Store generic creator/assigner/assignee actors. | Self-Task actor equality, other-assignee denial for members, Leader allowance, and cross-Project actor rejection. | `DONE` | tasks_agent / 2026-08-15 | Approved Task head `e38e2cdea912160b183c65398c4e8d5682c1b00e`; actor/assignee/IDOR evidence passed review. |
| I1-TSK-02 | Validate optional due dates against Project dates and current global days off. | Boundary dates accepted; outside/day-off dates rejected. | `DONE` | tasks_agent / 2026-08-15 | Due-date/calendar validation and retained field-error behavior approved at `e38e2cdea912160b183c65398c4e8d5682c1b00e`. |
| I1-TSK-03 | Enforce the complete fixed Task status graph through current-assignee authorization. | Parameterized allowed/forbidden transition matrix and ID denial. | `DONE` | tasks_agent / 2026-08-15 | Server graph and legal UI choices approved at `e38e2cdea912160b183c65398c4e8d5682c1b00e`. |
| I1-TSK-04 | Add append-only Task comments for active members, current Leader, and owning Mentor. | Unauthorized/non-member and completed-Project mutation denial. | `DONE` | tasks_agent / 2026-08-15 | Comment authorization, lock order, and completed-history behavior approved at `e38e2cdea912160b183c65398c4e8d5682c1b00e`. |
| I1-TSK-05 | Show Task list/detail and initial DONE/non-deleted progress/status counts. | Empty Project renders `N/A`; soft/deleted data not yet exposed as current. | `DONE` | tasks_agent / 2026-08-15 | Progress/N/A, assignee display, visibility, and dashboard ordering approved at `e38e2cdea912160b183c65398c4e8d5682c1b00e`. |
### 4.5 `work/reports-ui`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I1-UI-01 | Establish Tailwind tokens and reusable Thymeleaf shell/fragments. | Fragment rendering, local assets, no unauthorized navigation items. | `DONE` | reports_ui_agent / 2026-08-15 | Shared local-asset shell and persistent SMTP restriction contract verified at `039fe25c7c2622a015c8962892dd99ff58be321d`. |
| I1-UI-02 | Implement the supported desktop sidebar/header, forms, tables, badges, alerts, confirmations, empty/error states, and theme bootstrap. | Keyboard labels/focus, no desktop page-level overflow, pre-paint theme application. | `DONE` | reports_ui_agent / 2026-08-15 | Edge/Chromium 1365x900 focus, collapse tooltip, theme pre-paint, and overflow gates verified at `039fe25c7c2622a015c8962892dd99ff58be321d`. |
| I1-UI-03 | Build basic Admin, Mentor, and Intern dashboards from real queries. | Role-correct metrics/actions; illustrative data never leaks into production paths. | `DONE` | reports_ui_agent / 2026-08-15 | Public service/DTO dashboards and role-correct navigation verified at `039fe25c7c2622a015c8962892dd99ff58be321d`. |
| I1-UI-04 | Integrate bootstrap, authentication, Project, Task, and attendance pages into the shared shell. | Critical MockMvc web flows and server-side authorization. | `DONE` | reports_ui_agent / 2026-08-15 | Integrated shell, SMTP five-step journey, forms, history, errors, and authorization verified at `039fe25c7c2622a015c8962892dd99ff58be321d`. |
### 4.6 Iteration 1 integration gate
Integration order:
1. `work/platform`
2. `work/projects`
3. `work/attendance`
4. `work/tasks`
5. `work/reports-ui`
Exit demonstration:
- First Admin bootstraps the installation.
- SMTP is tested through Mailpit.
- Admin creates and activates Mentor and Intern accounts.
- Mentor atomically creates a Project with its first Leader, directly adds another member, and activates it.
- Ordinary member creates a self-assigned Task; Leader creates and assigns another Task.
- Assignee changes Task status and comments.
- Intern checks in and checks out.
- Every role sees only authorized navigation, actions, and records.
- Full integrated tests pass at the iteration integration commit.
Taskmaster exit result (2026-08-15): `DONE` at main integration commit `b9b150ff8ca9333e3b46d77537ec91875a970d57`, incorporating reviewed candidate `039fe25c7c2622a015c8962892dd99ff58be321d` and the verified development configuration/documentation follow-up. The final merged-main PostgreSQL 18.4 suite passed 197/197; Flyway produced 23 application tables and 56 foreign keys; Node 24/Tailwind assets built successfully; compile and full Javadoc/doclint passed on the reviewed candidate. A real local Java 25 process completed fresh bootstrap, login, five-step SMTP deferral, persistent restriction recovery, and a separate real Mailpit draft/test/activate flow with aggregate health `UP`. Temporary exit databases and Mailpit were removed; the existing development PostgreSQL service remained intact. Project/Task/Attendance role flows are protected by the approved branch suites and real desktop E2E evidence. Application containerization, Compose, CI, and production liveness/readiness hardening remain deferred as recorded above.
## 5. Iteration 2 — Complete business workflows
Iteration 2 starts only after every continuing branch incorporates the integrated Iteration 1 `main`.
### 5.1 `work/platform`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I2-PLAT-01 | Complete account lock/unlock/deactivation and session invalidation. | State graph, authentication denial, retained attribution. | `TODO` | — | — |
| I2-PLAT-02 | Implement activation failure/resend and forgot/reset-password workflows. | Failed token invalidation, single-use/expiry, generic enumeration-safe responses. | `TODO` | — | — |
| I2-PLAT-03 | Implement internship start activation, completion, and withdrawal guards. | Scheduler plus request-time activation; Leader/unfinished-Task terminal guards. | `TODO` | — | — |
| I2-PLAT-04 | Complete encrypted SMTP and HolidayAPI draft/test/active revision lifecycles. | AES-GCM round trip, wrong-key failure, one active revision, no browser secret disclosure. | `TODO` | — | — |
| I2-PLAT-05 | Expose the tested VN HolidayAPI client to the attendance module. | Valid preview, invalid key/rate limit/unavailable responses without local-data outage. | `TODO` | — | — |
| I2-PLAT-06 | Persist in-app notifications and initial ordinary-email delivery states, including Project invitation and membership-exit created/resolved types. | Recipient deduplication, domain commit independent of SMTP, self-Task silence, and `UNAVAILABLE`/sent/failed behavior. | `TODO` | — | — |
### 5.2 `work/tasks`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I2-TSK-01 | Add dated 11440-minute Task work logs and author corrections. | Membership/date boundaries and author-only editing. | `TODO` | — | — |
| I2-TSK-02 | Enforce the combined 1440-minute daily total across all Projects. | PostgreSQL integration and concurrent over-allocation proof. | `TODO` | — | — |
| I2-TSK-03 | Reassign unfinished Tasks while preserving creator, state, comments, and work history and updating generic assignment actor/time. | DONE requires assignee reopen; creator attribution and prior logs/comments remain unchanged. | `TODO` | — | — |
| I2-TSK-04 | Implement Task edit/soft deletion and authorized historical inspection for Leader and self-Task creator. | Leader controls any unfinished Task; creator controls only while still current assignee; deleted Task leaves normal progress but remains historical. | `TODO` | — | — |
| I2-TSK-05 | Provide Task transfer and completion-query operations to the Projects module. | Atomic bulk transfer behavior and non-deleted/DONE counts. | `TODO` | — | — |
| I2-TSK-06 | Complete Project status counts, percentage, total minutes, and per-member work queries. | Empty `N/A`, authorization scopes, hand-checkable totals. | `TODO` | — | — |
### 5.3 `work/projects`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I2-PRJ-01 | Issue and revoke non-expiring Leader invitations while retaining the issuing leadership term. | Eligibility, one pending Project/Intern pair, Leader-own versus Mentor-any revocation, ordinary notification behavior. | `TODO` | — | — |
| I2-PRJ-02 | Let only the intended authenticated Intern accept or decline; support Mentor direct-add supersession. | Email is not a bearer join token; exactly one membership; terminal status/code and provenance retained. | `TODO` | — | — |
| I2-PRJ-03 | Create/cancel Leader-removal and member-leave requests without changing current rights. | Nonblank reason, same-Project/type shape, one pending request per target, requester-only cancellation. | `TODO` | — | — |
| I2-PRJ-04 | Let only the owning Mentor approve/reject exits, using the assisted unfinished-Task transfer and Leader-replacement transaction. | Transfer/replacement/interval/request resolution commit together; reject/cancel changes request only. | `TODO` | — | — |
| I2-PRJ-05 | Retain leadership history, change Leader without moving assignments, and complete only when every non-deleted Task is `DONE`. | Exactly one current Leader in PLANNED/ACTIVE; completion closes intervals, revokes invitations, and supersedes exits. | `TODO` | — | — |
| I2-PRJ-06 | Provide invitation, exit, former-member, and completed-Project historical read-only views. | Historical visibility and attribution without stale mutation authority. | `TODO` | — | — |
### 5.4 `work/attendance`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I2-ATT-01 | Schedule future-month attendance-policy versions, including separate grace values, and preserve effective history. | First-of-future-month rule; effective immutability; old checkout cutoff/report stability. | `TODO` | — | — |
| I2-ATT-02 | Preview/import VN HolidayAPI candidates with Admin selection, override, provenance, and deduplication. | Public suggestion not authority; manual fallback; repeated import safety. | `TODO` | — | — |
| I2-ATT-03 | Materialize frozen full-day leave allocations and monthly/cross-month quota reservations. | Workday/day-off classification, policy snapshot, quota per month. | `TODO` | — | — |
| I2-ATT-04 | Implement leave submit/approve/reject/cancel and overlap protection. | Same-day boundary, pending/approved reservations, concurrent overlap/quota. | `TODO` | — | — |
| I2-ATT-05 | Implement missed-checkout correction submission and effective-checkout derivation. | Reject before/at checkout cutoff; accept afterward through scheduled end +24 hours; raw checkout remains null. | `TODO` | — | — |
| I2-ATT-06 | Implement Mentor approve/reject/revert and separate decision-window locking. | Valid state graph, concurrent decision, expired pending auto-rejection. | `TODO` | — | — |
| I2-ATT-07 | Add idempotent schedulers and equivalent request-time deadline guards. | Late/multiple scheduler invocation cannot duplicate transitions/notifications. | `TODO` | — | — |
### 5.5 `work/reports-ui`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I2-UI-01 | Complete Admin/Mentor/Intern/Leader dashboards and notification UI. | Authorization-correct actions and deadline/state summaries. | `TODO` | — | — |
| I2-UI-02 | Complete account, integration, Project, invitation, membership-exit, Task, policy/calendar, attendance, correction, and leave desktop workflows using shared fragments. | Leader invite/revoke/removal request, Intern accept/decline/leave/cancel, and Mentor exit decision forms retain safe input and expose conflicts clearly. | `TODO` | — | — |
| I2-UI-03 | Build one authorized attendance/compliance HTML report dataset. | Date filters, detailed versus own scope, formulas and `N/A`. | `TODO` | — | — |
| I2-UI-04 | Build one authorized Project/Task HTML report dataset. | Project/member/status/date filters and per-member visibility rules. | `TODO` | — | — |
| I2-UI-05 | Add only meaningful Chart.js trends with adjacent text/table alternatives. | Accessible label, equivalent data, theme tokens, reduced motion. | `TODO` | — | — |
### 5.6 Iteration 2 integration gate
Integration order:
1. `work/platform`
2. `work/tasks`
3. `work/projects`
4. `work/attendance`
5. `work/reports-ui`
Exit demonstration:
- Admin schedules a future policy without changing historical output.
- Admin previews/imports holidays and overrides a suggested day-off decision.
- Intern submits cross-month leave and Mentor decides it.
- Intern submits a missed-checkout correction; Mentor decides and may revert it inside the window.
- Leader reassigns an unfinished Task while preserving work history.
- Leader invites an eligible Intern; the signed-in Intern accepts or declines; Mentor direct-add safely supersedes a pending invite.
- Mentor changes Leader without moving the former Leader's Tasks.
- Member requests to leave and Leader requests removal; Mentor rejects or approves through assisted transfer and required replacement.
- Mentor completes a Project after all non-deleted Tasks are done.
- Ordinary domain actions retain in-app notifications when SMTP is unavailable.
- Full integrated tests pass at the iteration integration commit.
## 6. Iteration 3 — Hardening, reports, and delivery readiness
Iteration 3 starts only after every continuing branch incorporates the integrated Iteration 2 `main`.
### 6.1 `work/platform`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I3-PLAT-01 | Add normalized-email-plus-source-IP login throttling. | Five-in-15 and 15-minute throttle boundaries; successful-login clearing. | `TODO` | — | — |
| I3-PLAT-02 | Finish ordinary email retry schedule and terminal failure handling. | Exact 1m/5m/30m/2h/12h attempts; idempotent bounded worker. | `TODO` | — | — |
| I3-PLAT-03 | Add token cleanup and production-safe operational/status views. | Expired token behavior, no secret/stack/SQL disclosure. | `TODO` | — | — |
| I3-PLAT-04 | Enforce production HTTPS/origin/proxy/header/cookie/master-key readiness. | Prod fails unsafe configuration; dev/test relax only transport/origin controls. | `TODO` | — | — |
| I3-PLAT-05 | Produce the non-root application image and bundled/external PostgreSQL deployment modes. | Same immutable image becomes healthy in both configurations. | `TODO` | — | — |
| I3-PLAT-06 | Finalize Gitea verification, main-only OCI publication, and disabled SSH deployment/rollback template. | Work branches never publish; disabled deploy receives no secrets; exact-SHA flow is testable when enabled. | `TODO` | — | — |
### 6.2 `work/projects`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I3-PRJ-01 | Harden concurrent membership, invitation acceptance/direct-add, exit approval, and leadership operations. | One valid winner; locks/rechecks cover invitation, Project, membership, leadership, request, and unfinished Tasks; stale requests produce explicit conflict without partial history. | `TODO` | — | — |
| I3-PRJ-02 | Complete the global-role/context/ownership authorization matrix for direct membership, invitations, exits, and leadership. | Direct-ID, stale Leader, wrong invitee, cross-Mentor/member, requester, target, and decision-maker negative cases. | `TODO` | — | — |
| I3-PRJ-03 | Prove completed/historical read-only behavior and terminal Intern guards. | No mutation through UI or direct request after lifecycle closure. | `TODO` | — | — |
| I3-PRJ-04 | Verify Project list/progress query indexes and bounded performance. | Explain plan/catalog evidence for actual report paths. | `TODO` | — | — |
### 6.3 `work/tasks`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I3-TSK-01 | Harden concurrent status, reassignment, deletion, and daily-minute operations. | Optimistic conflicts and serialized daily total. | `TODO` | — | — |
| I3-TSK-02 | Complete due-date impact behavior for later-created global days off. | Existing due date retained and disclosed; new/changed due date rejected. | `TODO` | — | — |
| I3-TSK-03 | Complete former-assignee work-log correction and historical-deletion boundaries. | Author/member/Project lifecycle matrix. | `TODO` | — | — |
| I3-TSK-04 | Complete Task authorization/ID-guessing matrix and progress query verification. | Admin/Mentor/Leader/member/creator/current-assignee distinctions, creator-right loss after reassignment, generic same-Project actors, and real query indexes. | `TODO` | — | — |
### 6.4 `work/attendance`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I3-ATT-01 | Finalize attendance-rate and compliance formulas plus `N/A` denominators. | Hand-derived expected values across absence, leave, days off, and violations. | `TODO` | — | — |
| I3-ATT-02 | Prove historical stability after workday, schedule, check-in/checkout grace, quota, penalty, and calendar changes. | Before/after report equality plus unchanged cutoff for an older attendance row. | `TODO` | — | — |
| I3-ATT-03 | Harden concurrent leave quota/overlap and correction-decision races. | PostgreSQL exclusion plus transactional locking/optimistic conflicts. | `TODO` | — | — |
| I3-ATT-04 | Prove request-time and scheduler equivalence at checkout/correction boundaries and both expiry windows. | Pre-cutoff correction rejection; inclusive submission deadline; delayed/repeated scheduler produces one final transition. | `TODO` | — | — |
| I3-ATT-05 | Complete terminal-Intern and date-classification edge cases. | Terminal date with/without attendance, approved leave, and global day off. | `TODO` | — | — |
### 6.5 `work/reports-ui`
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|---|---|---|---|---|---|
| I3-UI-01 | Export attendance/compliance and Project/Task datasets to XLSX. | Parse workbook and compare filters, rows, totals, and `N/A` with HTML. | `TODO` | — | — |
| I3-UI-02 | Export the same datasets to PDF through a print-safe template and embedded Unicode font. | Vietnamese sample text and HTML/XLSX/PDF total parity. | `TODO` | — | — |
| I3-UI-03 | Complete desktop light/dark themes and all required desktop screens/states. | Theme before paint, system override, contrast, error/empty/stale/unavailable states. | `TODO` | — | — |
| I3-UI-04 | Complete WCAG-focused keyboard, focus, labels, icon names, and chart alternatives. | Web/accessibility evidence for representative critical pages. | `TODO` | — | — |
| I3-UI-05 | Add critical end-to-end flows across the integrated application. | Bootstrap/onboarding, Project/Task, attendance/correction/leave, and reports. | `TODO` | — | — |
| I3-UI-06 | Perform best-effort narrow-screen smoke checks only. | Prevent catastrophic corruption where practical; no mobile parity or mobile mockup gate. | `TODO` | — | — |
### 6.6 Iteration 3 integration gate
Integration order:
1. `work/platform`
2. `work/projects`, `work/tasks`, and `work/attendance` after their platform dependencies are available
3. `work/reports-ui`
Exit demonstration:
- Historical results and checkout cutoffs remain stable after later policy/calendar changes.
- Concurrent bootstrap, invitation/direct-add, membership exit/transfer, leadership, Task, leave, and correction operations fail safely.
- HTML, XLSX, and PDF expose identical authorized totals.
- Production refuses unsafe origin, proxy, datasource, or master-key configuration.
- The same non-root image works with bundled and external PostgreSQL.
- Work branches verify without publishing; only `main` publishes immutable SHA and convenience tags.
- SSH deployment remains dormant without secrets or an explicit enable variable.
- Desktop light/dark and accessibility acceptance passes; mobile/tablet remains best-effort only.
- Full integrated tests pass at the final integration commit.
## 7. Mandatory TDD workflow
Every feature follows this sequence:
1. Select a requirement and acceptance scenario.
2. Write the smallest behavioral test.
3. Run it and confirm the intended RED failure.
4. Record the RED command/result in the appropriate evidence file.
5. Write the minimum production code required for GREEN and add its meaningful Javadoc in the same implementation milestone.
6. Run the focused test.
7. Run the affected module/integration/web suite.
8. Refactor without weakening assertions.
9. Run the affected suite again.
10. Record final commands/results and commit SHA.
Recommended history:
```text
test(attendance): prove 09:00 check-in grace boundary [RED]
feat(attendance): enforce inclusive check-in grace boundary [GREEN]
```
The RED and GREEN commits may be pushed together after the branch head is green. The failing historical commit proves test-first order without leaving the remote branch intentionally broken.
Required evidence locations:
| Test level | Evidence directory |
|---|---|
| Unit/state/calculation | `docs/tests/unit/` |
| PostgreSQL/module integration | `docs/tests/integration/` |
| MockMvc/Thymeleaf/security web behavior | `docs/tests/web/` |
| Cross-module/browser journey | `docs/tests/e2e/` |
Every evidence Markdown record must include:
- requirement and scenario IDs;
- protected behavior and why it matters;
- test method and hand-derived expected result;
- exact RED command and relevant failure;
- exact GREEN and affected-suite commands/results;
- external dependency or environment boundaries;
- final commit SHA when available.
Javadoc is part of production implementation, not a later documentation phase. Every new or materially changed production type and every declared public/protected method shall document its business contract, including non-obvious authorization, transaction/locking, lifecycle/history, unit, timezone, or deadline semantics. Do not add prose that merely repeats names. Iteration 1 alone may retrofit Javadocs after feature implementation is complete; those branch-owned retrofit commits still require affected verification and independent scoped re-review. Every later iteration and turn shall add/update Javadocs during the implementation milestone.
## 8. Branch-level test emphasis
| Branch | Non-negotiable evidence |
|---|---|
| `work/platform` | Bootstrap concurrency, token lifecycle, account/security authorization, encryption, SMTP failure, session invalidation, production-profile failure. |
| `work/projects` | Lifecycle graphs, ownership, membership/invitation/exit/leadership intervals, transfer transactions, optimistic locking, guessed-ID denial. |
| `work/tasks` | Parameterized status graph, Leader/member creator/assignee distinction, self-Task and same-Project generic actors, daily-minute concurrency, progress totals. |
| `work/attendance` | Injected-Clock boundaries, PostgreSQL overlap/uniqueness, policy history, quota concurrency, schedulers and request-time guards. |
| `work/reports-ui` | MockMvc forms/authorization, accessible rendering, report query totals, XLSX/PDF parsing/parity, critical browser journeys. |
## 9. Iteration handoff protocol
At each iteration boundary:
1. Each branch owner updates every claimed item to `DONE`, `BLOCKED`, or returns it to `TODO`.
2. The owner provides commit SHA, evidence paths, exact verification commands, and remaining risk.
3. Integrate branches in the iteration's declared order.
4. Resolve cross-module conflicts through the owning branch rather than duplicating code in the integrator.
5. Run the full affected integrated suite.
6. Record the integration commit in Section 3.
7. Bring the integrated `main` into all five persistent work branches before the next iteration begins.
Handoff template:
```markdown
### <tracker ID> — <short title>
- Status: DONE | BLOCKED | TODO
- Owner:
- Requirements/scenarios:
- RED evidence:
- GREEN evidence:
- Focused verification:
- Affected-suite verification:
- Commit SHA:
- Remaining risk/blocker:
- Required next owner/action:
```
## 10. Global definition of done
A tracker item is complete only when:
- the numbered requirement and acceptance behavior are satisfied;
- the test existed and failed for the intended reason before production code;
- focused and affected suites pass;
- authorization and negative cases are covered where applicable;
- PostgreSQL-specific rules are tested against PostgreSQL, not H2;
- concurrency/deadline/history behavior has proportionate evidence;
- UI behavior uses server-side authorization and shared fragments;
- documentation/evidence paths are recorded in this tracker;
- new or changed production types and public/protected methods have accurate Javadoc created during implementation (Iteration 1 retrofit exception only);
- no unrelated files or another branch's ownership area were changed without coordination;
- the final branch head is green;
- integration does not alter totals, state graphs, or historical meaning.
## 11. Progress log
Append material coordination events only. Do not duplicate every commit.
| Date/time | Agent/person | Event | Tracker IDs | Evidence/commit | Next action |
|---|---|---|---|---|---|
| — | — | Plan initialized; no implementation item claimed. | — | — | Obtain requirements approval and begin Iteration 1. |
| 2026-08-15 | Taskmaster + five feature owners | Retrofitted targeted Lombok boilerplate after every owner fast-forwarded to the latest `main`; all five exact heads passed independent review and the combined merge passed 201 PostgreSQL 18.4 tests, compile, Javadoc/doclint, deterministic assets, and final integration review. | Iteration 1 maintenance | Platform `c9499732`; Project `58712710`; Task `469d7e27`; Attendance `5ddbd75f`; Reporting `8500cb6e`; integrated `b764707716f54ad164547b087f658c277cf46e0f`; `docs/tests/unit/lombok-*-boilerplate.md` | Push reviewed `main`, then fast-forward all five persistent work branches before Iteration 2 work. |
@@ -0,0 +1,210 @@
---
name: orchestrate-labtimesheet-iteration
description: Use when initiating, resuming, integrating, or completing a multi-branch Lab Timesheet iteration defined by .agents/PROJECT_PLAN.md.
---
# Orchestrate a Lab Timesheet Iteration
Run the iteration continuously from repository audit through an evidence-backed local exit demonstration. Preserve the five long-lived work branches and keep business features isolated behind public services and DTOs.
## Load authority and preserve state
1. Read the repository `AGENTS.md`, `.agents/PROJECT_PLAN.md`, `PRODUCT.md`, `DEVELOPMENT.md`, `TESTING.md`, and the applicable numbered requirements in `labtimesheet-docs-hub/requirements-specification.md`.
2. Apply authority in this order: current user decisions, numbered requirements, approved plan, tests/code, then inference.
3. Inspect every worktree and the root `git status` before mutation. Record and preserve unrelated dirty or untracked files.
4. Confirm the actual Maven, Spring Boot, Java, Node, PostgreSQL, Flyway, and Testcontainers versions. Use the documented project baseline even if a newer JDK is installed.
5. Create or update the active goal and plan only when the user requests goal-mode execution.
## Enforce the project structure
Keep `LabtimesheetApplication` in `com.lab.labtimesheet`, shared wiring in `config`, and business code under:
```text
feature.account
feature.integration
feature.project
feature.task
feature.attendance
feature.notification
feature.reporting
```
Repeat only the layers a feature needs: `controller`, `model`, `model.dto`, `model.entity`, `repository`, `service`, and `exception`. Mirror these packages in tests.
- Call another feature only through its concrete public service methods and DTOs.
- Never import another feature's repository or JPA entity.
- Use Spring Data JPA for business persistence.
- Limit direct SQL to Flyway and schema/catalog verification.
- Do not add empty `common`, `core`, `utils`, or boundary-placeholder packages.
- Do not add one-implementation interfaces, shadow mappings of foreign tables, or speculative abstractions.
Use the installed Lombok processor as the default for safe Java boilerplate:
- `@RequiredArgsConstructor` for injection-only constructors with required final dependencies;
- targeted `@Getter`, `@Setter`, and protected `@NoArgsConstructor` instead of blanket `@Data`;
- no generated JPA entity `equals`, `hashCode`, or `toString` over mutable state, associations, or secrets;
- records remain records for immutable DTOs and commands;
- explicit constructors and methods remain when they validate, normalize, enforce invariants, preserve history, define identity, or select qualified dependencies.
Require each owner to audit its production package, record a source-contract RED for eligible handwritten boilerplate, make the smallest behavior-preserving Lombok conversion, and prove compile/Javadoc, Spring injection, JPA mapping, template property access, and affected behavior remain green. Do not add annotations that generate unused API.
## Establish the shared baseline first
Before dispatching feature work:
1. Verify the Maven wrapper, application profile, Flyway baseline, PostgreSQL connectivity, test-only clock/encryption, and frontend asset toolchain.
2. Run a clean baseline test and capture any pre-existing failure separately.
3. Run the application locally against a disposable or dedicated PostgreSQL service. A PostgreSQL or Mailpit test container is acceptable; do not containerize the application when the exit gate says local process.
4. Commit the shared platform foundation before dependent branches use it.
5. Hand off only full immutable commit SHAs from clean worktrees.
## Own five branches and worktrees
Use exactly these persistent branches unless the user changes the plan:
| Branch | Ownership |
|---|---|
| `work/platform` | Baseline, migration, accounts, security, bootstrap, integrations, notification plumbing |
| `work/projects` | Projects, membership, leadership, lifecycle, project authorization |
| `work/tasks` | Tasks, comments, work logs, status, task progress |
| `work/attendance` | Policy, calendar, attendance, corrections, leave, metrics |
| `work/reports-ui` | Shared Thymeleaf UI, dashboards, reports, exports |
Create one isolated worktree per branch. Give each implementation agent explicit ownership, tell it other agents share the repository, forbid reverting others' work, require medium-milestone local commits, and forbid push unless separately authorized.
Before any owner edits its module, require it to:
1. verify its worktree has no uncommitted changes;
2. confirm the taskmaster's exact latest `main` SHA;
3. fast-forward its persistent work branch to that SHA with `git merge --ff-only main`;
4. prove `git rev-parse HEAD` equals the supplied main SHA;
5. stop rather than resolving unexpected divergence or overwriting user work.
Store durable coordination under `.superpowers/sdd/PROJECT_PLAN/`:
- `progress.md` with exact SHAs, dependency pins, tests, blockers, and review rounds;
- one task report per branch;
- review packages and reviewer reports.
Trust this ledger and Git history after context compaction. Never redispatch a completed milestone.
## Require RED, GREEN, Javadoc, and companion evidence
For every behavior:
1. Read its requirement and acceptance scenario IDs.
2. Write the narrowest production-shaped test first.
3. Run it and record RED for the expected missing behavior, not an environment or test defect.
4. Implement the minimum coherent change and its meaningful Javadoc in the same milestone. Document every new or materially changed production type and declared public/protected method, emphasizing business contracts, authorization, transactions/locking, state/history semantics, units, and time boundaries rather than restating names.
5. Run focused GREEN, affected-suite verification, then the branch-wide suite.
6. Refactor only while tests remain green.
7. Commit when a medium milestone is complete.
Use PostgreSQL 18.4 Testcontainers for persistence semantics; do not substitute H2. Keep time and randomness controllable. Test public outcomes, persisted state, authorization denial, and boundary cases.
Create a Markdown evidence record under the matching directory:
```text
docs/tests/unit/
docs/tests/integration/
docs/tests/web/
docs/tests/e2e/
```
Start from that directory's `_TEMPLATE.md`. Every record must include requirement/scenario IDs, protected behavior, test method, hand-derived expected result, exact RED command/result, exact GREEN and affected-suite commands/results, implementation milestone, and external-test boundaries. Never replace executable evidence with a verbal claim.
Iteration 1 is the only retrofit exception: after all feature tasks finish, return each branch to its original owner to add missing Javadocs before integration, rerun affected verification, commit, and undergo scoped re-review. In every later iteration or turn, reject delayed Javadoc cleanup; it belongs in the implementation milestone.
## Merge dependencies by immutable pin
Derive the dependency graph from the current iteration plan. For Iteration 1, use:
```text
platform foundation
-> projects base
-> attendance
-> tasks (projects + attendance public APIs)
-> projects activation guard (final Task query API)
-> reports-ui (final public dashboard APIs)
```
- A producer reports a clean full SHA and public API before a consumer merges it.
- Never merge a moving branch or a short ambiguous SHA.
- Preserve dirty consumer work during the merge and immediately rerun its structure test.
- If a consumer needs data, add the smallest producer-owned public query DTO/service method; never map or query the producer's tables locally.
- Keep final integration order from `.agents/PROJECT_PLAN.md` even when development dependencies require a temporary producer/base round trip.
- Reuse the last reviewed consumer branch as the integrated candidate when it already contains every approved producer pin in order. An extra integration branch adds no safety by itself.
- After a Platform merge, run the architecture test and every `@WebMvcTest` controller slice. Global advice and shared beans can invalidate otherwise unrelated slice fixtures; fix only the test fixture, never production security to accommodate a slice.
## Coordinate agents without losing control
Let the five owners work in parallel where dependencies permit. Resolve normal in-scope questions without pausing the run. Stop only for a genuine blocker, consequential ambiguity, destructive action, or sensitive external effect requiring user approval.
For a sensitive action, state the exact effect and obtain approval in the root task. If a child cannot inherit that approval, the root may apply only the isolated approved patch; return the worktree to its owner for tests, evidence, self-review, and commit.
Do not accept an agent's completion claim alone. Require:
- exact branch and full SHA;
- clean worktree;
- focused, affected, and full test counts;
- confirmation that new/changed production APIs carry accurate Javadoc;
- a list of Lombok conversions and explicit boilerplate deliberately retained with its business reason;
- evidence/report path;
- public API handoff;
- blockers and unverified boundaries;
- confirmation of no push.
## Review only after all five owners finish
After every implementation owner reports `DONE`, dispatch independent code-review assignments, one branch per assignment. Give each reviewer the plan/requirements paths, branch report, ledger, merge base, full diff package, and exact structure/TDD constraints.
Each review must return both spec-compliance and code-quality verdicts with file/line evidence. For Critical or Important findings:
1. Send the complete finding list back to that branch's original owner.
2. Require a focused regression test, RED when applicable, GREEN, affected suite, evidence update, and a fix commit.
3. Dispatch an independent scoped re-review of only the fix range.
4. Repeat up to five rounds; use a fresh stronger fixer for rounds four and five.
5. At the cap, record a reasoned ruling for non-load-bearing findings or stop on a load-bearing blocker.
Do not merge a branch with unresolved load-bearing findings.
## Prove the iteration exit gate
Integrate only reviewed immutable branch SHAs in the plan's order. Then verify from the integrated tree:
1. `git diff --check` and package/JPA architecture tests.
2. Flyway replay against fresh PostgreSQL and the required table/foreign-key catalogue checks.
3. Full Maven tests on the documented Java version and PostgreSQL engine.
4. Frontend asset build on the pinned Node version.
5. A local application process connected to the configured PostgreSQL service.
6. Health/readiness and the iteration's real role-correct web workflow.
7. No application containerization when explicitly deferred.
8. Root dirty-state preservation, clean reviewed worktrees, and no unauthorized push.
Keep environment authority explicit:
- real `.env` files are local and ignored;
- `.env.example` contains placeholders only;
- the development Spring profile maps every required runtime value from the environment;
- Admin-console SMTP and HolidayAPI secrets never move into `.env`;
- configuration-only work uses a shell/configuration RED and real application smoke test instead of an artificial Java unit test.
If SMTP participates in health, exercise both states deliberately: application startup with SMTP deferred, and aggregate health with a real temporary Mailpit connection. Report the distinction instead of calling one state universally healthy.
Store cross-module browser journeys in `docs/tests/e2e/`. Before completion, update every Iteration row and the durable progress ledger; stale `IN_PROGRESS` rows are an incomplete gate even when tests pass.
Review source Javadocs against behavior as part of the branch and integration diff; stale or content-free Javadoc does not satisfy the gate.
## Merge reviewed Iteration work to main
Only enter this phase with explicit user authority.
1. Add any final environment example, profile configuration, implementation-status README, development guide, and testing/TDD guide to the integrated candidate under RED/GREEN evidence.
2. Run the complete PostgreSQL suite and a real local-process smoke test on that exact candidate.
3. Fetch the remote base. Stop on unexpected divergence; never force-push to hide it.
4. Preserve unrelated dirty root files. Commit only separately authorized tracked root guidance before merging if the incoming branch also changes that file.
5. Merge the exact candidate into `main` without squashing or rewriting the reviewed branch history.
6. Rerun the full suite on merged `main`, inspect the diff, and verify `.env` is ignored and absent from the index.
7. Push `main` normally, verify the remote ref equals local `HEAD`, and keep host-managed worktrees unless cleanup was explicitly requested.
Record the integration SHA, commands, outputs, limitations, and deferred next-iteration scope. Mark the goal complete only after every stated exit condition has fresh evidence.
@@ -0,0 +1,4 @@
interface:
display_name: "Orchestrate Lab Timesheet Iteration"
short_description: "Run five-branch TDD and Javadoc delivery"
default_prompt: "Use $orchestrate-labtimesheet-iteration to execute the current iteration across the five work branches with TDD, Javadoc, and review gates."
+1 -1
View File
@@ -1,4 +1,4 @@
# Copy to .env, replace every placeholder, then load it into the IDE or shell.
# Copy to .env and replace every placeholder. The dev profile imports it from the repository root.
SPRING_PROFILES_ACTIVE=dev
LAB_SERVER_PORT=8080
LAB_FORWARD_HEADERS_STRATEGY=NONE
+1 -11
View File
@@ -34,15 +34,5 @@ node_modules/
### VS Code ###
.vscode/
### Agentic ###
.agents/
.agent/
AGENTS.md
# Local requirements review artifacts
/labtimesheet-docs-hub/
# Local Impeccable product context
/PRODUCT.md
/DESIGN.md
/.impeccable/
/labtimesheet-docs-hub/
+18
View File
@@ -0,0 +1,18 @@
{
"northStar": "The Calm Operations Ledger",
"rules": [
"Neutral structure carries the interface; semantic color is rare.",
"Use border and tone before shadow.",
"Put one role-correct task and action in the first viewport.",
"Desktop is supported; mobile and tablet are best-effort."
],
"shadows": {
"panel": "0 10px 28px rgba(20,25,35,.06)",
"active": "0 1px 2px rgba(0,0,0,.06)"
},
"breakpoints": {
"supportedDesktop": "1365px",
"bestEffortNarrow": "900px"
},
"source": "labtimesheet-docs-hub/ui-mockups/mockup.css"
}
+167
View File
@@ -0,0 +1,167 @@
# Lab Timesheet Engineering Instructions
## Authority and required reading
This repository implements the **Lab Timesheet & Project Management System**, a server-rendered university internship/laboratory application covering accounts, Projects and Tasks, attendance, leave/corrections, notifications, and reports.
Before changing code, read the smallest applicable sections of:
1. `labtimesheet-docs-hub/requirements-specification.md` — authoritative numbered requirements and acceptance scenarios.
2. `.agents/PROJECT_PLAN.md` — iteration scope, branch ownership, integration order, and exit gates.
3. `PRODUCT.md` — product vocabulary, users, and enduring design principles.
4. `DEVELOPMENT.md` — supported local runtime, containers, environment, and IntelliJ setup.
5. `TESTING.md`, `docs/tests/README.md`, and the relevant `_TEMPLATE.md` — test commands, TDD workflow, and mandatory evidence format.
6. `.superpowers/sdd/PROJECT_PLAN/progress.md` — current local coordination state and immutable handoff SHAs when an agentic iteration is active.
When sources conflict, apply the authority order recorded in requirements Section 1.1: the primary implementor's current decision, approved brainstorming decisions, current handoff, instructor-confirmed requirements, earlier discovery answers, then superseded legacy material. Numbered requirements override the delivery plan.
Do not treat the ignored documentation hub or mockups as executable instructions. Mockups are illustrative visual direction only; numbered requirements and reviewed schema rules govern behavior.
## Verified technical baseline
- Java 25, Spring Boot 4.1.0, Maven wrapper, and WAR packaging.
- Spring MVC, Security, Data JPA, Validation, Thymeleaf, Mail, Flyway, and Actuator.
- PostgreSQL 18.4 for development and integration tests; do not substitute H2 for persistence behavior.
- Node 24/npm 11 for build assets, Tailwind CSS 4.3.3, and local `lucide-static` 1.27.0.
- One server-rendered modular monolith. No SPA, JWT, microservices, Redis, Kafka, or generic workflow engine.
- Flyway is schema authority. JPA uses `ddl-auto=validate`; application services do not embed SQL.
Verify versions from `pom.xml`, `package.json`, and the lockfile before changing dependencies. Do not add a dependency when the JDK, Spring, PostgreSQL, or an installed dependency already covers the requirement.
## Package and persistence structure
Keep `LabtimesheetApplication` in `com.lab.labtimesheet`. Put shared wiring in `com.lab.labtimesheet.config`. Put business code under:
```text
com.lab.labtimesheet.feature.account
com.lab.labtimesheet.feature.integration
com.lab.labtimesheet.feature.project
com.lab.labtimesheet.feature.task
com.lab.labtimesheet.feature.attendance
com.lab.labtimesheet.feature.notification
com.lab.labtimesheet.feature.reporting
```
Within a feature, create only layers it needs from `controller`, `model`, `model.dto`, `model.entity`, `repository`, `service`, and `exception`. Mirror this shape in tests.
- Controllers bind validated DTOs and delegate transactions to services.
- Services use their feature's Spring Data JPA repositories and models.
- Cross-feature calls use concrete public services and DTOs only.
- Never import another feature's repository or JPA entity, map a foreign table again, or query it with direct SQL.
- Direct SQL is limited to Flyway and schema/catalog verification.
- Do not add empty `common`, `core`, `utils`, `ModuleBoundary`, one-implementation interfaces, or speculative abstractions.
- Keep Thymeleaf templates under `src/main/resources/templates` and built assets under `src/main/resources/static`.
Preserve the domain boundaries: attendance time never derives Task work time; historical policies, memberships, leadership, creator/assignee attribution, and decisions do not silently move when current configuration changes.
## Lombok is the default for Java boilerplate
Lombok is already installed and configured as an annotation processor. Use it by default when it removes mechanical Java without hiding a business rule.
- Use `@RequiredArgsConstructor` for Spring controllers, services, configuration classes, and other components whose constructor only assigns required `final` dependencies. Keep an explicit constructor when it validates input, transforms data, selects among same-typed beans, or documents a non-trivial public contract.
- Use targeted annotations such as `@Getter`, `@Setter`, `@NoArgsConstructor`, and `@AllArgsConstructor`; use the smallest set that matches the actual API. Do not use `@Data` as a blanket shortcut.
- For JPA entities, never let Lombok generate `equals`, `hashCode`, or `toString` across entities, lazy associations, mutable fields, or encrypted secrets. Prefer `@Getter` and `@NoArgsConstructor(access = AccessLevel.PROTECTED)`, keep domain constructors and mutation methods explicit, and add individual setters only when a framework genuinely needs them.
- Keep Java records for immutable DTOs and commands. Replacing a record with a Lombok class creates more code and is not an improvement.
- Use `@Slf4j` only when the class actually logs. Do not add builders, withers, or generated setters speculatively.
- Do not retain handwritten constructors, getters, setters, `equals`, `hashCode`, or `toString` that are purely mechanical and safely covered by the targeted Lombok annotation. Preserve explicit methods that enforce invariants, normalize values, maintain history, or define identity semantics.
- After a Lombok refactor, inspect the generated API contract, run compile/Javadoc plus the affected tests, and confirm JPA mappings, Spring injection, Thymeleaf property access, serialization, and security-sensitive redaction remain unchanged.
This rule applies during implementation, not as deferred cleanup. A source-audit RED may prove existing eligible boilerplate before a behavior-preserving Lombok refactor; the GREEN gate is the same public behavior with less handwritten code.
## Javadoc is part of implementation
Add meaningful Javadoc while implementing production Java code, in the same milestone and before its final GREEN/commit.
- Document every new or materially changed production type and every public or protected method declared in source.
- Explain business purpose and non-obvious contracts: authorization/context requirements, transaction or locking behavior, state transitions, history retention, side effects, units, timezone/deadline boundaries, and null/empty semantics.
- Keep inherited Javadoc for a true override when it fully describes the contract. Generated Lombok methods, trivial accessors, and tests do not need duplicate prose.
- Do not write comments that merely restate names or implementation steps. If a contract cannot be explained clearly, simplify the code or clarify the requirement.
- Update Javadoc whenever behavior changes; stale Javadoc is a defect.
Iteration 1 is the one approved retrofit exception: feature owners add missing Javadocs after their implementation tasks finish, then rerun affected verification and undergo scoped re-review. Every later iteration and turn must add Javadocs during implementation, not as cleanup.
## Mandatory TDD and evidence
Use strict RED → GREEN → affected-suite verification → refactor:
1. Select requirement and acceptance-scenario IDs.
2. Write the smallest production-shaped failing test.
3. Run it and prove the RED is the missing behavior, not a broken fixture or environment.
4. Record the exact RED command/result in the matching evidence file.
5. Implement the minimum behavior and its Javadoc.
6. Run focused GREEN, then the affected suite; refactor only while green.
7. Update evidence with exact commands/results and external boundaries.
8. Commit a medium-sized green milestone locally.
Evidence belongs under:
```text
docs/tests/unit/
docs/tests/integration/
docs/tests/web/
docs/tests/e2e/
```
Copy the directory's `_TEMPLATE.md`; do not invent a second format. PostgreSQL-specific behavior uses PostgreSQL 18.4 Testcontainers. Security, ownership, concurrency, deadlines, and history require negative and boundary tests proportionate to risk.
## Five-branch ownership and subagent workflow
The persistent implementation branches are:
| Branch | Primary ownership |
|---|---|
| `work/platform` | Maven/app baseline, Flyway, accounts/security/bootstrap, integrations/notifications, container and CI assets |
| `work/projects` | Projects, membership/leadership intervals, invitations/exits, lifecycle, Project authorization |
| `work/tasks` | Tasks, actor/assignee rules, comments, work logs, status, progress |
| `work/attendance` | Policy/calendar, attendance, corrections, leave, schedulers, metrics |
| `work/reports-ui` | Shared Thymeleaf UI, dashboards, reports/exports, UI/accessibility consistency |
For a multi-branch iteration:
- Use one worktree and one named owner/subagent per branch. Tell every owner that other agents share the repository and it must not revert others' work.
- Before starting assigned module work, every owner verifies its worktree is clean, fetches or uses the taskmaster-verified latest `main`, and fast-forwards its persistent branch to that exact main SHA. Do not build new work on a stale pre-integration branch, and do not use a merge that would rewrite or discard branch history.
- Establish and commit the platform foundation before dependent persistence work.
- Exchange only full immutable SHAs from clean worktrees; never merge a moving branch or ambiguous short SHA.
- Preserve branch ownership. Request a producer-owned service/DTO boundary instead of reading its tables from a consumer.
- Commit each medium green milestone locally. Do not push, publish, deploy, force, rewrite history, or merge to `main` without explicit authority.
- After all five owners report DONE, run independent read-only reviews of every branch. Return Critical/Important findings to the original owner with a regression test where applicable, GREEN evidence, a fix commit, and scoped re-review. Do not integrate unresolved load-bearing findings.
- Integrate reviewed exact heads only in the current iteration's order from `.agents/PROJECT_PLAN.md`, then run the full integrated exit gate.
Keep durable coordination under `.superpowers/sdd/PROJECT_PLAN/`: progress ledger, branch reports, review findings, immutable SHAs, commands/results, blockers, and integration evidence. Do not redispatch completed milestones after context compaction.
## Local commands and runtime
Default development expects PostgreSQL on port `55432`; override with `LAB_DB_URL`, `LAB_DB_USERNAME`, and `LAB_DB_PASSWORD`. SMTP defaults to localhost Mailpit port `1025` and can be overridden with `LAB_SMTP_HOST`/`LAB_SMTP_PORT`. Never commit real secrets.
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw test
npm ci
npm run build
./mvnw spring-boot:run
```
When using local OrbStack Testcontainers, set the actual Docker socket for that machine. Tests must not depend on the developer database or a real SMTP server.
Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL; an application container does not substitute for that gate when containerization is deferred.
## Post-iteration integration and push
- Use the final reviewed consumer branch as the integration candidate when it already contains every approved producer SHA in plan order; do not create an extra integration branch without a concrete need.
- Keep real `.env` files untracked. Commit only `.env.example` placeholders and environment-backed Spring profile configuration. SMTP and HolidayAPI credentials managed by the Admin console do not belong in `.env`.
- Keep `README.md`, `DEVELOPMENT.md`, and `TESTING.md` aligned with the merged application. Document only commands and workflows that were exercised or directly verified.
- Configuration and README changes made after feature review still require a configuration-contract RED, focused GREEN, the complete PostgreSQL suite, `git diff --check`, and a local-process smoke test before merging.
- A global MVC advice or shared configuration bean can affect every `@WebMvcTest` slice. After merging Platform changes, run all controller slices and add only the missing test fixture bean; do not weaken the production advice.
- Record real cross-module browser journeys under `docs/tests/e2e/`, not `docs/tests/web/`, and update `.agents/PROJECT_PLAN.md` plus the progress ledger before declaring the iteration complete.
- Before merging to `main`, fetch its upstream and stop if the remote moved unexpectedly. Preserve unrelated root changes, stage only authorized paths, merge without rewriting history, rerun the full suite on the exact merged tree, then push normally and verify the remote SHA.
- Worktrees under `/private/tmp` are host-managed. Keep the five branch worktrees and branches after integration unless the user explicitly requests cleanup.
## Safety and scope
- Inspect `git status` before editing and preserve unrelated dirty/untracked files.
- Use server time and an injectable `Clock` for deadline behavior; never trust browser event timestamps.
- Keep CSRF, authorization, password hashing, validation, and ownership checks active in every environment.
- Never print, persist, or return raw activation/reset tokens except the approved immediate delivery path; persist only their hashes.
- Desktop is the supported UI target. Mobile responsiveness is best-effort and has no mockup/parity gate.
- Iteration scope is exact. Leave later-iteration capabilities TODO rather than adding placeholders or partial frameworks.
+192
View File
@@ -0,0 +1,192 @@
---
name: Lab Timesheet
description: A quiet, high-density operations system for internship attendance and Project work.
colors:
ink: "#15171a"
canvas: "#f6f7f8"
sidebar: "#f0f1f2"
panel: "#ffffff"
panel-muted: "#f7f8f9"
border: "#dfe1e5"
border-strong: "#c9cdd3"
text-muted: "#626a75"
text-subtle: "#818894"
accent: "#3157e7"
success: "#087a48"
warning: "#996000"
danger: "#b42318"
typography:
headline:
fontFamily: "ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "25px"
fontWeight: 700
lineHeight: 1.2
letterSpacing: "-0.025em"
body:
fontFamily: "ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "14px"
fontWeight: 400
lineHeight: 1.45
label:
fontFamily: "ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "11px"
fontWeight: 650
lineHeight: 1.45
rounded:
control: "8px"
tab: "9px"
surface: "12px"
dialog: "14px"
spacing:
xs: "4px"
sm: "8px"
md: "16px"
lg: "24px"
components:
button-primary:
backgroundColor: "{colors.ink}"
textColor: "{colors.panel}"
rounded: "{rounded.control}"
padding: "8px 13px"
height: "37px"
input:
backgroundColor: "{colors.panel}"
textColor: "{colors.ink}"
rounded: "{rounded.control}"
padding: "9px 10px"
height: "39px"
panel:
backgroundColor: "{colors.panel}"
textColor: "{colors.ink}"
rounded: "{rounded.surface}"
---
# Design System: Lab Timesheet
## Overview
**Creative North Star: "The Calm Operations Ledger"**
Lab Timesheet is a permission-aware operations workspace. It favors legible state, compact controls, clear ownership, and reviewable records over decorative dashboard theater. The Vercel/shadcn-style reference is translated into server-rendered Thymeleaf surfaces with a stable shell and one role-correct task per page.
The visual system is quiet but not empty: thin structure, disciplined spacing, tabular data, and rare semantic color make consequential actions and deadlines easy to find. Example content must always be identified as illustrative.
**Key Characteristics:**
- Cool near-white surfaces with charcoal text.
- Fixed desktop sidebar and compact page header.
- Dense tables and direct forms as the main working surfaces.
- Black primary actions; color reserved for focus, status, warning, and error.
- Rounded corners and restrained ambient shadow, never floating card mosaics.
## Colors
The light palette uses cool neutral layers; dark mode must be designed from the supplied near-black references rather than mechanically inverted from these values.
### Primary
- **Operational Ink** (`#15171a`): primary text, brand mark, and primary actions.
- **Controlled Accent** (`#3157e7`): focus, selected data, and rare contextual emphasis.
### Neutral
- **Work Canvas** (`#f6f7f8`): page background.
- **Navigation Shell** (`#f0f1f2`): desktop sidebar.
- **Record Surface** (`#ffffff`): forms, tables, panels, and active navigation.
- **Muted Surface** (`#f7f8f9`): headers, tabs, and supporting rows.
- **Ledger Border** (`#dfe1e5`): default one-pixel structure.
- **Control Border** (`#c9cdd3`): inputs and action outlines.
- **Muted Text** (`#626a75`) and **Subtle Text** (`#818894`): secondary and tertiary copy.
### Semantic
- **Success** (`#087a48`), **Warning** (`#996000`), and **Danger** (`#b42318`) communicate state with text and shape, never color alone.
**The Rare Color Rule.** Neutral structure carries the interface. Semantic and accent colors appear only when they clarify state, focus, validation, or a consequential decision.
## Typography
**Display and Body Font:** the local system sans stack (`ui-sans-serif`, platform UI fonts, `Segoe UI`, sans-serif). No remote font is required.
**Character:** compact, familiar, and operational. Weight and spacing establish hierarchy without oversized marketing display type.
### Hierarchy
- **Page headline** (700, `25px`, 1.2): one per screen.
- **Panel title** (600700, `14px`): names the current dataset or decision surface.
- **Body** (400, `14px`, 1.45): instructions and explanatory copy, normally no wider than 72ch.
- **Data** (500650, `12px`): dense tables and facts; numeric summaries use tabular numerals.
- **Label** (650, `11px`): fields and supporting metadata.
- **Navigation group label** (750, `10px`, uppercase, `0.08em`): rare structural labels only.
**The One Headline Rule.** Each screen gets one page headline; hierarchy below it is compact and task-oriented.
## Layout
Desktop is the supported product target. The shell uses a 236px sidebar and a minimum-width content column, with a 60px header and 2426px content inset. The implementation target may round the sidebar to approximately 16rem and its collapsed rail to approximately 4rem.
Content uses a four-cell metric strip, full-width table/form panels, and an occasional two-column decision or form/detail layout. The primary record or decision remains in the first viewport at 1365×900. Tables may scroll horizontally inside their own region but must not create page-level overflow.
Mobile and tablet responsiveness is best-effort only. It may reflow or scroll to avoid preventable breakage, but it is not required to provide complete workflow parity and has no dedicated mockup set.
## Elevation & Depth
Structure comes primarily from surface contrast and one-pixel borders. The only recurring ambient shadow is a soft panel lift (`0 10px 28px rgba(20,25,35,.06)`); active navigation and tabs use a smaller `0 1px 2px` shadow. Deep stacks and card-within-card effects are not part of this world.
**The Flat-First Rule.** Use border and tone before shadow. Shadow confirms grouping; it does not turn every region into a floating card.
## Shapes
Controls use an 8px radius, segmented containers 9px, primary panels 12px, and centered dialogs 14px. Status badges may be fully rounded because their small silhouette communicates state. Larger containers are not pill-shaped. Borders are neutral and one pixel.
## Components
### Buttons
- **Primary:** Operational Ink background, white text, 8px radius, 37px minimum height.
- **Secondary:** white background, stronger neutral border, same geometry.
- **Danger:** pale danger surface with explicit consequence copy; destructive actions require confirmation.
- **Focus:** visible high-contrast focus treatment is mandatory in implementation.
### Tables and panels
- Panels use white surface, one-pixel border, 12px radius, and optional ambient shadow.
- Table headers use muted surface, compact uppercase labels, and stable desktop columns.
- Status is shown with a text badge plus a non-color cue.
- Empty, unavailable, stale, and access-denied states replace the table body with direct operational copy.
### Inputs and forms
- Fields use white surface, stronger neutral border, 8px radius, and 39px minimum height.
- Labels stay visible; placeholder text never replaces a label.
- Validation preserves safe input, associates field errors, and adds a form-level error summary.
- Form actions appear once, at the end of the form; list/detail actions live in the page header.
### Navigation
- The desktop sidebar shows only authorized destinations.
- Active navigation uses a white surface and ink text without a colored stripe.
- The lower account area exposes profile, theme, and logout.
- Icon-only collapsed navigation requires accessible names and tooltips.
### Metric strips and tabs
- Metric strips are one bounded row divided by one-pixel rules, not separate floating cards.
- Tabs are compact segmented controls; a tab labels a true view switch, not a decorative category badge.
## Do's and Don'ts
### Do:
- **Do** put the users current task, deadline, record state, and authorized action in the first viewport.
- **Do** use server-authoritative dates/times and honest illustrative-data labels.
- **Do** keep role, ownership, membership, leadership, and assignee distinctions visible in copy and action placement.
- **Do** supply accessible labels, keyboard focus, error summaries, and chart text/table alternatives.
### Don't:
- **Don't** use gradients, glass effects, remote fonts, decorative charts, or oversized marketing headings.
- **Don't** use cards as the default container for every piece of content.
- **Don't** expose an action merely because the current global role sounds powerful enough; contextual authorization wins.
- **Don't** treat the desktop mockups as mobile requirements or imply mobile workflow parity.
- **Don't** invent production endorsements, adoption metrics, or unlabeled example records.
+21 -11
View File
@@ -124,18 +124,21 @@ unless you intentionally want to discard your local development data.
## 4. Run from a terminal
Load the environment file in the same terminal that will run Spring Boot:
The `dev` profile imports the ignored root `.env` file automatically. From the
repository root, run:
```bash
set -a
source .env
set +a
./mvnw spring-boot:run
```
Shell environment variables still override values from `.env`, which is useful
for a one-off local override. If you run from another working directory, set
`LAB_DEV_ENV_FILE` to the absolute path of your `.env` file.
Open:
- First-Admin setup: `http://localhost:8080/bootstrap`
- First-Admin setup: open `http://localhost:8080` and follow the automatic
redirect to `/bootstrap`.
- Login: `http://localhost:8080/login`
- Mailpit inbox: `http://localhost:8025`
@@ -177,14 +180,14 @@ Stop the application with `Control+C`.
6. Set **JRE** to Java 25.
7. Set **Active profiles** to `dev`.
8. Set **Working directory** to the repository root.
9. Open the **Environment variables** editor and add every variable from your
local `.env` file.
9. Leave **Environment variables** empty. With the repository root as the
working directory, `application-dev.yaml` imports the ignored `.env` file.
10. Apply the configuration and run it.
Some IntelliJ editions can load variables from an environment file directly.
If that option is available, select the local `.env`; otherwise use the
environment-variable table. Do not store real secrets in a shared or committed
run configuration.
If company policy requires IntelliJ to inject the values instead, select the
local `.env` in the **Environment variables** field. Environment variables take
precedence over the imported file. Do not store real secrets in a shared or
committed run configuration.
Run `npm ci` and `npm run build` in IntelliJ's terminal before the first launch
and after changing Tailwind or icon sources.
@@ -220,6 +223,13 @@ does not by itself prove that SMTP is unavailable; use the Admin SMTP test.
Check both **Project SDK** and the run configuration's **JRE**. They should both
be Java 25.
### Spring reports an unresolved `LAB_*` placeholder
Confirm the run configuration uses the repository root as its working
directory and that `.env` exists there. If the working directory must differ,
set `LAB_DEV_ENV_FILE` to the absolute `.env` path in the run configuration's
environment variables.
### Styles or icons are missing
Run:
+89
View File
@@ -0,0 +1,89 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Stack
- Java 25 and Spring Boot 4.1.0.
- Maven-built, server-rendered Spring MVC modular monolith organized by business feature.
- Spring Security, Spring Data JPA, Bean Validation, Thymeleaf, Spring Mail, and Flyway.
- Tailwind CSS 4 with Node 24 LTS used only for frontend build assets.
- PostgreSQL 18.4 across development, integration testing, and production.
- No SPA framework, JWT authentication, microservices, Redis, Kafka, or generic workflow engine in v1.
## Users
- **Admins** operate accounts, internship lifecycles, attendance policy, the global calendar, SMTP, HolidayAPI, and system configuration. They inspect Project and attendance progress but do not perform Mentor or Project Leader work.
- **Mentors** own Projects, directly manage membership and leadership, decide membership exits, monitor Project and Intern progress, comment on Tasks, inspect attendance, and decide leave and missed-checkout corrections.
- **Interns** check in and out, request leave and missed-checkout corrections, respond to their own Project invitations, request/cancel their own Project exit, participate in multiple Projects, create self-assigned Tasks, perform assigned Tasks, comment, update their own assigned Task status, and record Task work.
- A **Project Leader** is an Intern with a current leadership term for one Project. It is contextual authority, never a global account role. The Leader may invite eligible Interns, request a member's removal, and manage Task definitions/assignment inside that Project.
- The product is reviewed and maintained by a university project team and its instructor or appointed maintainer.
## Product Purpose
Lab Timesheet supports a university laboratory or internship program by bringing account administration, attendance, leave, missed-checkout correction, Project work, Task progress, notifications, and authorized reporting into one working system.
Success means each role can complete its permitted work without spreadsheets or informal message trails, while deadlines, decisions, historical attribution, and report totals remain explainable and auditable.
## Positioning
The product joins attendance oversight and Project delivery without pretending they are the same measurement. Check-in and checkout establish attendance; dated Task work logs establish Project effort. Effective-dated policy and frozen historical allocations prevent later configuration changes from rewriting past results.
## Operating Context
- The business timezone is `Asia/Ho_Chi_Minh`; business dates use the applicable attendance-policy timezone and persisted event instants are treated as UTC.
- The system is operated as one server-rendered web application with PostgreSQL. Desktop browsers are the supported interface target; mobile and tablet behavior is best-effort and is not guaranteed to expose every workflow optimally.
- Initial installation uses a one-time first-Admin bootstrap. Later account creation and password recovery depend on a tested SMTP configuration.
- Admins may preview and import Vietnamese holiday candidates from HolidayAPI, while the stored Admin decision remains authoritative. Manual calendar management remains available.
- Mentors review global leave and correction queues and separately oversee only the Projects they own.
- Reports cover attendance/compliance and Project/Task progress in HTML, Excel, and PDF from one shared dataset definition.
- The authoritative requirements are currently a review draft. Product-context initialization does not authorize application implementation or promote the review DDL into Flyway.
## Capabilities and Constraints
- Global account roles are exactly `ADMIN`, `MENTOR`, and `INTERN`, and are immutable after account creation.
- Project membership is many-to-many and interval-based. The owning Mentor may add/remove directly and makes every exit decision; the current Leader may invite; only the intended authenticated Intern may accept/decline; members may request but cannot unilaterally leave.
- Every `PLANNED` or `ACTIVE` Project has exactly one current Intern Leader. Any active member may create a Task assigned only to themselves; only the current Leader may create for another member or reassign broader Task work.
- Each Task has one current assignee. Only that assignee changes its status and records work.
- Attendance uses server-time check-in and checkout. Effective-dated policy stores separate check-in and checkout grace periods, both defaulting to 30 minutes; with the default 15:30 end, normal checkout closes immediately after the inclusive 16:00:00 cutoff. Task work is a separate dated-minute record and never proves attendance.
- Leave is full-day. Only frozen eligible workdays consume quota, and pending or approved requests reserve it.
- Corrections apply only to missing checkout after the attendance row's historical checkout cutoff. Submission remains open through scheduled end plus 24 hours, and the Mentor then receives a separate 24-hour decision window.
- Global attendance policy is effective-dated; historical attendance and leave allocations must not drift after later policy or calendar changes.
- SMTP and HolidayAPI secrets are Admin-managed and encrypted with a deployment-provided master key. Email-dependent account actions fail closed when SMTP is unavailable; other domain actions retain in-app delivery.
- HTML, Excel, and PDF reports must agree on the same hand-checkable totals and render undefined denominators as `N/A`.
- The product language is English in v1. Displayed business dates use `dd/MM/yyyy` and times use 24-hour local time.
- Features absent from the reviewed requirements are not silently in scope.
## Brand Commitments
- The working product name is **Lab Timesheet & Project Management System**, shortened to **Lab Timesheet** where space is constrained.
- The supplied Vercel/shadcn-style operations-shell images are illustrative references for a compact permission-aware application shell with supported light and dark modes. They do not define fields, workflows, authorization, or persistence and never override numbered requirements or reviewed DDL.
- Interface copy must be direct, operational, and honest about permissions, deadlines, destructive consequences, unavailable integrations, and illustrative data.
## Evidence on Hand
- `labtimesheet-docs-hub/requirements-specification.md` is the authoritative requirements review draft.
- `labtimesheet-docs-hub/database-schema.sql` is the companion PostgreSQL design baseline, not yet a production migration.
- `labtimesheet-docs-hub/assets/ui-reference-light.png`, `ui-reference-dark-shell.png`, and `ui-reference-dark-dashboard.png` are the supplied visual references.
- The repository contains an early Spring Boot scaffold matching the recorded Java/Spring/Maven direction but no implemented product interface yet.
- No production data, customer testimonials, adoption metrics, institutional endorsements, or performance claims are available. Future design work must not fabricate them.
## Product Principles
1. **Authorization follows stored context.** Global role alone is insufficient; ownership, membership, leadership, assignment, lifecycle, and record scope determine access.
2. **History does not move.** Later policy, calendar, membership, invitation, exit decision, leadership, assignment, or assignee changes must not silently rewrite past results, Task creator attribution, or provenance.
3. **Attendance and Project work stay distinct.** The product may report them together, but one never derives or proves the other.
4. **Deadlines are enforced at every path.** Scheduled workers improve timeliness, while request-time guards preserve correctness when scheduling is late.
5. **Prefer explicit, reviewable operations.** Feature-owned controller/service/repository flows, constrained state transitions, focused integrations, and shared report datasets serve clarity over speculative machinery.
## Accessibility & Inclusion
- The web interface must meet WCAG 2.2 AA contrast and interaction requirements in both light and dark themes.
- Controls require associated labels or accessible names, visible keyboard focus, keyboard operation, and adequate target sizes.
- Status and validation cannot depend on color alone. Forms retain safe input, identify field errors, and provide an error summary.
- Charts are supplemental: every canvas requires an accessible label and an adjacent textual or tabular alternative.
- Desktop navigation and data tables must remain fully operable without page-level horizontal overflow. Mobile and tablet layouts should avoid preventable breakage on a best-effort basis but are not a fully supported v1 target.
+5 -6
View File
@@ -75,9 +75,6 @@ console, not environment variables.
```bash
cp .env.example .env
# Edit .env. Generate LAB_SECURITY_MASTER_KEY with: openssl rand -base64 32
set -a
source .env
set +a
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
@@ -89,10 +86,12 @@ npm run build
Development defaults to the application on port `8080`, PostgreSQL on `55432`,
and Mailpit SMTP on `1025`. The exact Spring settings are in
[`application-dev.properties`](src/main/resources/application-dev.properties).
[`application-dev.yaml`](src/main/resources/application-dev.yaml), which imports
the ignored root `.env` file when the `dev` profile is active.
On first launch, open `http://localhost:8080/bootstrap`, create the first Admin,
then configure and test SMTP or complete all five explicit deferral warnings.
On first launch, open `http://localhost:8080`; the application redirects to
`/bootstrap`, where you create the first Admin. Then configure and test SMTP or
complete all five explicit deferral warnings.
## Verification status
+2 -1
View File
@@ -225,7 +225,8 @@ Run `java -version` and `./mvnw -version`. Both should report Java 25. Set
### The application cannot start for a manual browser check
Confirm `.env` was loaded, PostgreSQL is reachable, and
Confirm the process working directory is the repository root so
`application-dev.yaml` can import `.env`, PostgreSQL is reachable, and
`LAB_SECURITY_MASTER_KEY` decodes from Base64 to 32 bytes. Automated tests do
not need this local file.
@@ -3,36 +3,47 @@
- **Test type:** Integration
- **Requirement IDs:** `OPS-001`, `OPS-004`, `SEC-013`
- **Scenario IDs:** `AC-OPS-001`, `AC-SEC-005`
- **Test class/method:** Shell configuration contract plus the full Spring Boot Maven suite
- **Implementation commit:** `4212e9cbc2791e0c733929af62503df26097431e`
- **Test class/method:** Shell configuration contract, an unsourced dev-profile startup probe, and the full Spring Boot Maven suite
- **Implementation commit:** `531c6078521341b156d69cb1013e54f65c092311`
## Protected behavior
Development starts from environment-backed datasource, encryption, public-origin, server, proxy, and local Mailpit settings without committing a real `.env` or weakening application security controls.
Development starts from datasource, encryption, public-origin, server, proxy,
and local Mailpit values in the ignored root `.env` file without requiring an
IDE-specific environment-variable copy, committing secrets, or weakening
application security controls.
## Test method
A shell contract verifies that the committed placeholder and development properties exist, the superseded YAML is absent, the real `.env` is ignored, every required environment key is represented, and every application placeholder resolves after loading the local file. The full Maven suite then exercises Spring configuration binding, Flyway, JPA validation, security, and PostgreSQL behavior.
A shell contract verifies that the committed placeholder and development YAML
exist, the superseded properties file is absent, the YAML explicitly imports
the ignored root `.env`, and every required placeholder remains represented. A
real dev-profile process is launched without sourcing `.env` to prove Spring
loads it. The full Maven suite then exercises Spring configuration binding,
Flyway, JPA validation, security, and PostgreSQL behavior.
## Hand-derived expected result
The committed tree contains `.env.example` and `application-dev.properties`, never tracks `.env`, and exposes exactly the environment inputs needed by the current application. Loading the local file gives Spring a `dev` profile, PostgreSQL connection, 32-byte Base64 encryption key, public origin, local Mailpit endpoint, server port, and explicit no-forwarded-header policy.
The committed tree contains `.env.example` and `application-dev.yaml`, never
tracks `.env`, and exposes exactly the inputs needed by the current
application. Starting the `dev` profile from the repository root, without
exporting the file, gives Spring the PostgreSQL connection, 32-byte Base64
encryption key, public origin, local Mailpit endpoint, server port, and explicit
forwarded-header policy.
## RED
**Command**
```text
required_files=(.env.example src/main/resources/application-dev.properties); failed=0; for file in $required_files; do if [ ! -f "$file" ]; then echo "MISSING $file"; failed=1; fi; done; if [ -f src/main/resources/application-dev.yaml ]; then echo 'STALE src/main/resources/application-dev.yaml'; failed=1; fi; if ! grep -qx '/.env' .gitignore; then echo 'MISSING /.env ignore rule'; failed=1; fi; exit "$failed"
/bin/zsh -lc 'config_check_failed=0; if test -e src/main/resources/application-dev.properties; then echo "STALE application-dev.properties"; config_check_failed=1; fi; if ! test -f src/main/resources/application-dev.yaml; then echo "MISSING application-dev.yaml"; config_check_failed=1; fi; if test -f src/main/resources/application-dev.yaml && ! grep -Fq "optional:file:\${LAB_DEV_ENV_FILE:.env}[.properties]" src/main/resources/application-dev.yaml; then echo "MISSING .env import"; config_check_failed=1; fi; exit "$config_check_failed"'
```
**Observed result**
```text
MISSING .env.example
MISSING src/main/resources/application-dev.properties
STALE src/main/resources/application-dev.yaml
MISSING /.env ignore rule
STALE application-dev.properties
MISSING application-dev.yaml
exit 1
```
@@ -41,15 +52,28 @@ exit 1
**Command**
```text
required_files=(.env.example src/main/resources/application-dev.properties); required_env=(SPRING_PROFILES_ACTIVE LAB_SERVER_PORT LAB_FORWARD_HEADERS_STRATEGY LAB_DB_URL LAB_DB_USERNAME LAB_DB_PASSWORD LAB_SMTP_HOST LAB_SMTP_PORT LAB_PUBLIC_ORIGIN LAB_SECURITY_MASTER_KEY); required_props=(server.port server.forward-headers-strategy spring.datasource.url spring.datasource.username spring.datasource.password spring.jpa.hibernate.ddl-auto spring.jpa.open-in-view spring.flyway.enabled spring.mail.host spring.mail.port lab.public-origin lab.security.master-key); failed=0; for file in $required_files; do if [ ! -f "$file" ]; then echo "MISSING $file"; failed=1; fi; done; if [ -f src/main/resources/application-dev.yaml ]; then echo 'STALE src/main/resources/application-dev.yaml'; failed=1; fi; if ! grep -qx '/.env' .gitignore; then echo 'MISSING /.env ignore rule'; failed=1; fi; for key in $required_env; do if ! grep -q "^${key}=" .env.example; then echo "MISSING example $key"; failed=1; fi; if ! grep -q "^${key}=" .env; then echo "MISSING local $key"; failed=1; fi; done; for property in $required_props; do if ! grep -q "^${property}=" src/main/resources/application-dev.properties; then echo "MISSING property $property"; failed=1; fi; done; set -a; source .env; set +a; decoded_bytes=$(printf '%s' "$LAB_SECURITY_MASTER_KEY" | base64 -d | wc -c | tr -d ' '); if [ "$decoded_bytes" != 32 ]; then echo "INVALID master key bytes=$decoded_bytes"; failed=1; fi; if ! git check-ignore -q .env; then echo 'LOCAL .env is not ignored'; failed=1; fi; if git ls-files --error-unmatch .env >/dev/null 2>&1; then echo 'LOCAL .env is tracked'; failed=1; fi; if [ "$failed" -eq 0 ]; then echo 'development configuration contract: PASS'; fi; exit "$failed"
/bin/zsh -lc 'dev_contract_failed=0; dev_required_files=(.env.example src/main/resources/application-dev.yaml); dev_required_env=(SPRING_PROFILES_ACTIVE LAB_SERVER_PORT LAB_FORWARD_HEADERS_STRATEGY LAB_DB_URL LAB_DB_USERNAME LAB_DB_PASSWORD LAB_SMTP_HOST LAB_SMTP_PORT LAB_PUBLIC_ORIGIN LAB_SECURITY_MASTER_KEY); dev_required_placeholders=(LAB_SERVER_PORT LAB_FORWARD_HEADERS_STRATEGY LAB_DB_URL LAB_DB_USERNAME LAB_DB_PASSWORD LAB_SMTP_HOST LAB_SMTP_PORT LAB_PUBLIC_ORIGIN LAB_SECURITY_MASTER_KEY); for dev_file in $dev_required_files; do if ! test -f "$dev_file"; then echo "MISSING $dev_file"; dev_contract_failed=1; fi; done; if test -e src/main/resources/application-dev.properties; then echo "STALE application-dev.properties"; dev_contract_failed=1; fi; if ! grep -Fq "optional:file:\${LAB_DEV_ENV_FILE:.env}[.properties]" src/main/resources/application-dev.yaml; then echo "MISSING .env import"; dev_contract_failed=1; fi; if ! grep -qx "/.env" .gitignore; then echo "MISSING /.env ignore rule"; dev_contract_failed=1; fi; for dev_key in $dev_required_env; do if ! grep -q "^${dev_key}=" .env.example; then echo "MISSING example $dev_key"; dev_contract_failed=1; fi; if ! grep -q "^${dev_key}=" .env; then echo "MISSING local $dev_key"; dev_contract_failed=1; fi; done; for dev_key in $dev_required_placeholders; do dev_placeholder="\${${dev_key}}"; if ! grep -Fq "$dev_placeholder" src/main/resources/application-dev.yaml; then echo "MISSING YAML placeholder $dev_key"; dev_contract_failed=1; fi; done; set -a; source .env; set +a; dev_decoded_key_bytes=$(printf "%s" "$LAB_SECURITY_MASTER_KEY" | base64 -d | wc -c | tr -d " "); if test "$dev_decoded_key_bytes" != 32; then echo "INVALID master key bytes=$dev_decoded_key_bytes"; dev_contract_failed=1; fi; if ! git check-ignore -q .env; then echo "LOCAL .env is not ignored"; dev_contract_failed=1; fi; if git ls-files --error-unmatch .env >/dev/null 2>&1; then echo "LOCAL .env is tracked"; dev_contract_failed=1; fi; if test "$dev_contract_failed" -eq 0; then echo "development configuration contract: PASS"; fi; exit "$dev_contract_failed"'
```
**Observed result**
```text
development configuration contract: PASS
```
A real Java 25 process loaded `.env` and `application-dev.properties`, connected to PostgreSQL 18.4, validated Flyway/JPA, and started on the environment-overridden port 18081. With temporary Mailpit on the configured SMTP port, `/actuator/health` returned HTTP 200 with `UP`, and `/login` returned HTTP 200. The process shut down and the temporary Mailpit container was removed.
An additional Java 25 process was started with all `LAB_*` and
`SPRING_PROFILES_ACTIVE` environment variables removed. It loaded the root
`.env` through `application-dev.yaml`, connected to PostgreSQL 18.4, validated
Flyway/JPA, and started successfully. The process was then stopped cleanly.
```text
env -u SPRING_PROFILES_ACTIVE -u LAB_SERVER_PORT -u LAB_FORWARD_HEADERS_STRATEGY -u LAB_DB_URL -u LAB_DB_USERNAME -u LAB_DB_PASSWORD -u LAB_SMTP_HOST -u LAB_SMTP_PORT -u LAB_PUBLIC_ORIGIN -u LAB_SECURITY_MASTER_KEY -u LAB_DEV_ENV_FILE /bin/zsh -lc 'export JAVA_HOME=/opt/homebrew/opt/openjdk@25; export PATH="$JAVA_HOME/bin:$PATH"; ./mvnw -DskipTests spring-boot:run'
No active profile set, falling back to 1 default profile: "dev"
Database: jdbc:postgresql://localhost:55432/labtimesheet (PostgreSQL 18.4)
Started LabtimesheetApplication in 4.068 seconds
Graceful shutdown complete
BUILD SUCCESS
```
## Affected suite
@@ -57,12 +81,14 @@ A real Java 25 process loaded `.env` and `application-dev.properties`, connected
**Command and result**
```text
env -u SPRING_PROFILES_ACTIVE -u LAB_SERVER_PORT -u LAB_FORWARD_HEADERS_STRATEGY -u LAB_DB_URL -u LAB_DB_USERNAME -u LAB_DB_PASSWORD -u LAB_SMTP_HOST -u LAB_SMTP_PORT -u LAB_PUBLIC_ORIGIN -u LAB_SECURITY_MASTER_KEY /bin/zsh -lc 'export JAVA_HOME=/opt/homebrew/opt/openjdk@25; export PATH=/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH; export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock; ./mvnw test'
env -u SPRING_PROFILES_ACTIVE -u LAB_SERVER_PORT -u LAB_FORWARD_HEADERS_STRATEGY -u LAB_DB_URL -u LAB_DB_USERNAME -u LAB_DB_PASSWORD -u LAB_SMTP_HOST -u LAB_SMTP_PORT -u LAB_PUBLIC_ORIGIN -u LAB_SECURITY_MASTER_KEY -u LAB_DEV_ENV_FILE /bin/zsh -lc 'export JAVA_HOME=/opt/homebrew/opt/openjdk@25; export PATH=/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH; export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock; ./mvnw test'
Tests run: 197, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS in 01:33 using PostgreSQL 18.4 Testcontainers. No development environment value was present.
Tests run: 201, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS in 01:29 using PostgreSQL 18.4 Testcontainers.
```
## External-test boundaries
The committed example cannot prove another developer's local credentials. Product SMTP and HolidayAPI revisions remain Admin-console configuration and are intentionally absent from `.env`.
The committed example cannot prove another developer's local credentials or
an IDE working directory. Product SMTP and HolidayAPI revisions remain
Admin-console configuration and are intentionally absent from `.env`.
@@ -0,0 +1,161 @@
# Test Evidence: Eligible Intern picker query
- **Test type:** Integration
- **Requirement IDs:** ACC-014, ACC-019ACC-021, AUTH-001, PRJ-017, TST-001TST-010
- **Scenario IDs:** AC-ACC-009, AC-ACC-010, AC-PRJ-010 (selection-eligibility support)
- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder; #rejectsMissingBusinessDate
- **Implementation commit:** e70159a81b6445825f6d5f912ecf3c4aa3c1aa85
## Protected behavior
Pending, locked, deactivated, non-Intern, not-started, completed, and date-expired records must not appear in the
Account-owned Intern picker. An option is selectable only when both account and internship are ACTIVE and the
explicit business date lies within the inclusive internship range. The returned numeric user ID is the internal
submission identity, and options sort by display name then student code.
The public query rejects a missing business date with the documented actionable message instead of issuing an
ambiguous null-bound database query.
## Test method
The PostgreSQL 18.4 integration test persists valid account/profile combinations through the account feature's JPA
entities and repositories. It uses SQL only as a test fixture for future lock, deactivation, and completion states
whose production transitions are outside this change. It calls the public Account service query and compares the
complete immutable DTO sequence, including both inclusive date boundaries and unique user IDs.
Its separate null-date regression calls the same public service method and asserts the exact
<code>IllegalArgumentException</code> message documented by that method.
## Hand-derived expected result
For business date 2026-08-14, profiles starting on that date and ending on that date remain eligible. The only
expected options are Alpha / STU-100, Alpha / STU-200, and Zeta / STU-300, in that order. Every other seeded
row fails at least one account role/state, internship state, or inclusive date condition.
For a missing business date, the service must immediately throw
<code>IllegalArgumentException("Business date is required")</code>.
## RED
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
~~~
**Observed result**
~~~text
[ERROR] EligibleInternOptionIntegrationTest.java:[11,54] cannot find symbol
symbol: class EligibleInternOption
location: package com.lab.labtimesheet.feature.account.model.dto
BUILD FAILURE
~~~
## GREEN
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
~~~
**Observed result**
~~~text
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
~~~
## Review follow-up: missing business date
The public guard was temporarily removed solely to prove the new regression fails for the intended reason, then
restored exactly before the GREEN checks. The follow-up commit contains only the regression test and evidence.
### RED
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
~~~
**Observed result**
~~~text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
java.lang.AssertionError: Expecting code to raise a throwable.
BUILD FAILURE
~~~
### GREEN
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
~~~
**Observed result**
~~~text
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
~~~
## Affected suite
**Command and result**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
Selected account reports: 13 tests, 0 failures, 0 errors, 0 skipped.
./mvnw -Dtest=AccountActivationIntegrationTest test
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
./mvnw -Dtest=LayerStructureTest test
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
./mvnw test
Tests run: 105, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
~~~
### Review follow-up affected account-service checks
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw '-Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest' test
EligibleInternOptionIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
BootstrapIntegrationTest: 4 tests, 0 failures, 0 errors, 0 skipped
AccountActivationIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
Selected account-service reports: 8 tests, 0 failures, 0 errors, 0 skipped.
BUILD SUCCESS
~~~
## External-test boundaries
This query does not authorize Project membership itself; the consuming Project transaction must still recheck
membership and ownership invariants. It does not test the later lifecycle mutation workflows that produce locked,
deactivated, or completed rows.
@@ -0,0 +1,108 @@
# Test Evidence: Attendance targeted Lombok boilerplate retrofit
- **Test type:** Unit compiled-contract audit
- **Requirement IDs:** `ATT-001``ATT-012`, `CAL-001`, `CAL-006``CAL-009`
- **Scenario IDs:** `AC-ATT-001``AC-ATT-005`, `AC-CAL-003`, `AC-CAL-004`
- **Test class/method:**
`com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#generatedConstructorsPreserveParameterListsAndVisibility`,
`com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#immutableModelsRemainRecordsWithTheirComponentContracts`,
`com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#entitiesExposeOnlyIntentionalPublicAndProtectedDeclaredMethods`,
`com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#componentsExposeOnlyIntentionalPublicAndProtectedDeclaredMethods`
- **Implementation commit:** `82ad8202fd31f77db8c3932a902dba07cee70894`
## Protected behavior
Attendance uses the installed Lombok processor only for mechanical constructors while preserving the compiled API:
package-level Spring injection, protected JPA construction, immutable record components, domain constructors and
mutations, raw punch and attached-policy history rules, composite-key identity, and existing public method names.
## Test method
Reflection inspects compiled `feature.attendance` classes rather than source spelling. It verifies every constructor's
parameter order and modifier, every immutable model's record components, and the exact public/protected declared method
surface of each Attendance entity. The entity surface prevents generated bean getters/setters or entity
`equals`/`hashCode`/`toString` widening while explicitly retaining `AttendanceRecordEntity#setCheckOutAt` and the
`LeaveRequestDayId` identity methods. Exact controller and service surfaces likewise prevent Lombok from exposing
collaborator getters/setters or generated `equals`/`hashCode`/`toString` methods.
## Hand-derived expected result
Five injection-only components expose only their package-scoped dependency constructors. Six JPA/embeddable types
retain protected no-argument construction alongside their intentional domain constructors, and the stateless domain
service remains package-scoped. Seven immutable models remain records with the same component order and types. Entity
method surfaces contain only intentional domain conversion/access/mutation methods; only the composite key owns
`equals` and `hashCode`, and no Attendance entity declares `toString`. Both controllers and all four Attendance
services expose only their existing route or application/domain operations, never their injected collaborators.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceLombokBoilerplateTest test
```
**Observed result**
```text
Tests run: 3, Failures: 2, Errors: 0, Skipped: 0
The initial source audit first failed on AttendanceController because its mechanical dependency constructor remained,
and on AttendancePolicyEntity because its mechanical protected JPA constructor remained. The immutable-record and
business-method retention guard passed. After this RED established the retrofit gap, the permanent regression was
replaced with compiled reflection/API checks so formatting or annotation spelling cannot affect the result.
BUILD FAILURE
Process exited 1.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceLombokBoilerplateTest test
```
**Observed result**
```text
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 39, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL 18.4 started and Flyway applied V1 for the persistence and concurrency contexts.
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
The reflection audit does not replace Spring/JPA bootstrapping, MVC property access, PostgreSQL persistence, or
Javadoc/doclint. Those checks remain affected verification. No application behavior or public API is intentionally
changed by this retrofit.
Additional verification on the same source tree:
```text
./mvnw -DskipTests compile
BUILD SUCCESS
./mvnw -q -DskipTests compile dependency:build-classpath -Dmdep.outputFile=target/attendance-javadoc-classpath.txt
javadoc -quiet -Xdoclint:all -d target/attendance-javadocs -classpath "target/classes:$(tr -d '\n' < target/attendance-javadoc-classpath.txt)" -sourcepath src/main/java -subpackages com.lab.labtimesheet.feature.attendance
Process exited 0. The source frontend reported seven generated-constructor missing-comment warnings because it does not
expand Lombok constructors; repository policy exempts generated trivial constructors from duplicate Javadoc.
```
@@ -0,0 +1,90 @@
# Test Evidence: Platform Lombok boilerplate retrofit
- **Test type:** Unit
- **Requirement IDs:** `Engineering policy — targeted Lombok retrofit`
- **Scenario IDs:** `Source-audit RED/GREEN`
- **Test class/method:** `N/A — reproducible source audit; behavior is covered by the affected suites below`
- **Implementation commit:** `41448903aa924dc5852db8d7bb4d9319cb9f91a7`
## Protected behavior
Platform-owned Spring collaborators, request forms, and JPA entities must not retain eligible handwritten
dependency-assignment constructors, trivial accessors, or empty persistence constructors. The retrofit must preserve
constructor visibility, form normalization, entity encapsulation, defensive copies of credential/token bytes, and all
account, authentication, SMTP, and cross-feature behavior.
## Test method
The source audit searches only the 79 members classified as mechanical after reading every root/config,
`feature.account`, and `feature.integration` production type. It deliberately excludes normalized email/display-name
setters, defensive byte-array getters, domain constructors and factories, state transitions, the normalized
`AccountService` public-origin constructor, `SecretCipher` key construction, and the two-constructor JavaMail test
seam. No permanent annotation-presence test was added because annotations are an implementation detail; compilation
and production-shaped tests protect the real contracts.
## Hand-derived expected result
Before the retrofit the audit must find 79 eligible handwritten members and exit 1. After targeted Lombok generation,
the same audit must find none and exit 0, while the retained non-mechanical members remain explicit.
## RED
**Command**
```text
matches=$( { rg -n '^ (public )?(AccountController|BootstrapAccessFilter|BootstrapController|BootstrapService|DatabaseUserDetailsService|SmtpController|SmtpWarningAdvice|MailDeliveryService|SmtpConfigurationService)\(' src/main/java/com/lab/labtimesheet/feature/account src/main/java/com/lab/labtimesheet/feature/integration; rg -n '^ public (String getMasterKey|void setMasterKey)\(' src/main/java/com/lab/labtimesheet/config/SecurityProperties.java; rg -n '^ public (String get(Token|Password|ConfirmPassword)|void set(Token|Password|ConfirmPassword))\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java; rg -n '^ public (String get(Email|DisplayName|Password)|void setPassword)\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java; rg -n '^ public .+ (get(Email|DisplayName|Role|StudentCode|InternshipStart|InternshipEnd)|set(Role|StudentCode|InternshipStart|InternshipEnd))\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java; rg -n '^ public (Long getDraftId|void setDraftId)\(' src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java; rg -n '^ public .+ (get(Host|Port|SecurityMode|Username|Password|FromAddress|FromName)|set(Host|Port|SecurityMode|Username|Password|FromAddress|FromName))\(' src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java; rg -n '^ protected (AppUser|InternProfile|SystemState|UserActionToken|SmtpConfiguration)\(\)' src/main/java/com/lab/labtimesheet/feature/account/model/entity src/main/java/com/lab/labtimesheet/feature/integration/model/entity; rg -n '^ public .+ (get(Id|Email|DisplayName|PasswordHash|GlobalRole|AccountStatus|ActivatedAt|InternshipStatus|InternshipStartDate|InternshipEndDate|UserId|Purpose|ExpiresAt|UsedAt|InvalidatedAt|Status|Host|Port|SecurityMode|Username|SecretKeyVersion|FromAddress|FromName|TestedAt)|isInitialized)\(' src/main/java/com/lab/labtimesheet/feature/account/model/entity src/main/java/com/lab/labtimesheet/feature/integration/model/entity; } ); if [ -n "$matches" ]; then printf '%s\n' "$matches"; printf 'RED: eligible handwritten Lombok boilerplate remains (%s matches)\n' "$(printf '%s\n' "$matches" | wc -l | tr -d ' ')"; exit 1; fi; printf 'GREEN: no eligible handwritten Lombok boilerplate remains\n'
```
**Observed result**
```text
RED: eligible handwritten Lombok boilerplate remains (79 matches)
Process exited with code 1 because the confirmed mechanical members were still handwritten.
```
## GREEN
**Command**
```text
The exact RED source-audit command above was repeated without alteration.
```
**Observed result**
```text
GREEN: no eligible handwritten Lombok boilerplate remains
Process exited with code 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests compile
BUILD SUCCESS — 127 production source files compiled on Java 25.
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=LabtimesheetApplicationTests,LayerStructureTest,PlatformFoundationTest,TimeConfigurationTest,SecurityResponseIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SmtpIntegrationTest test
BUILD SUCCESS — Tests run: 29, Failures: 0, Errors: 0, Skipped: 0; PostgreSQL 18.4.
./mvnw test
BUILD SUCCESS — Tests run: 197, Failures: 0, Errors: 0, Skipped: 0; PostgreSQL 18.4.
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS — doclint reported no errors; Maven emitted 66 non-fatal missing-comment warnings across the integrated
tree, including generated default constructors/accessors.
git diff --check
No output; exit 0.
```
## External-test boundaries
This source audit does not prove Lombok internals or enforce a preferred annotation spelling. The compile and
PostgreSQL-backed affected/full suites prove generated constructor/accessor compatibility with Spring binding,
Security, JPA/Hibernate, Thymeleaf, and existing cross-feature consumers. No dependency, schema, migration, token,
credential, template, container, CI, or runtime configuration was changed.
@@ -0,0 +1,117 @@
# Test Evidence: Targeted Project Lombok boilerplate
- **Test type:** Temporary source audit (removed after GREEN)
- **Requirement IDs:** `ARC-002`, `ARC-005`, `ARC-006`, `TST-001`
- **Scenario IDs:** `AC-TST-001`
- **Test class/method:** N/A; the temporary source audit was removed after its RED/GREEN cycle
- **Implementation commit:** `e5639c1`
## Protected behavior
Project Spring components use targeted required-argument constructor generation only when their
constructors assign required final dependencies. Project JPA entities use only protected no-arg
constructor generation. Records remain records, and entity identity, lazy associations, explicit
domain accessors, aggregate constructors, and mutation methods do not gain broad generated APIs.
## Temporary RED/GREEN method
The temporary source-contract test inspected only `feature.project` production sources. It required
`@RequiredArgsConstructor` on the three injection-only components, removal of the stateless advice's
handwritten no-arg constructor, and protected `@NoArgsConstructor` on the three JPA entities. It also
rejected broad entity Lombok annotations, confirmed representative explicit domain APIs remained,
and verified every Project immutable DTO/value type remained a Java record. It was deleted after
preserving the historical RED/GREEN below because exact imports, annotation spelling, and source
substrings are implementation details rather than a durable public contract.
## Hand-derived expected result
Seven handwritten constructors are mechanical and eligible for removal: three dependency-assignment
constructors, one empty advice constructor, and three empty protected JPA constructors. The three
entity domain constructors, all aggregate mutation methods, defensive-copy accessors, derived
membership/leadership accessors, validation constructors, exception constructors, and all thirteen
records must remain explicit or remain records because they carry behavior or preserve the existing
API shape.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectLombokBoilerplateTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
ProjectLombokBoilerplateTest.eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis
expected ProjectController.java to contain import lombok.RequiredArgsConstructor; and
@RequiredArgsConstructor, but neither was present and the handwritten assignment-only constructor
remained.
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectLombokBoilerplateTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests compile
[INFO] BUILD SUCCESS
./mvnw -Dtest=ProjectEntityTest,ProjectPersistenceStructureTest,ProjectTaskMutationContextTest test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw -Dtest=ProjectControllerTest test
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=ProjectServiceIntegrationTest test
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw -Dtest='Project*Test' test
[INFO] Tests run: 44, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw -DskipTests -Dmaven.javadoc.failOnWarnings=true -Ddoclint=all \
-Dsubpackages=com.lab.labtimesheet.feature.project javadoc:javadoc
[INFO] BUILD SUCCESS
git diff --check
(no output; exit 0)
```
## External-test boundaries
The removed source audit did not prove Lombok annotation processing, Spring constructor injection,
Hibernate materialization, PostgreSQL mappings, Thymeleaf behavior, Project authorization, locking,
or aggregate lifecycle rules. Those durable boundaries are covered by the compile, scoped
Javadoc/doclint, Project unit/web, and PostgreSQL 18.4 integration gates above. The first sandboxed
unit-suite attempt could not attach Mockito's Byte Buddy agent; the unchanged command passed after
approved execution outside that sandbox. Browser E2E behavior remains outside this unit milestone.
@@ -0,0 +1,104 @@
# Test Evidence: Reporting Lombok boilerplate boundary
- **Test type:** Unit
- **Requirement IDs:** `ARC-005`, `OPS-019`, `OPS-021`
- **Scenario IDs:** `N/A — user-directed behavior-preserving refactor with no product acceptance scenario`
- **Test class/method:** Temporary executable source audit plus stable Reporting compile/behavior/architecture suites
- **Implementation commit:** `e2b206c27e494fbcd0cc3ed99ff8c2c470285f9e`
## Protected behavior
Reporting uses Lombok only for constructors that mechanically assign required Spring dependencies. Immutable dashboard DTOs remain records, the access-denied exception keeps its explicit superclass constructor, and the attendance-state presentation contract keeps its explicit fluent `label()` method.
## Test method
The narrow source audit reads only the four Reporting production sources. It requires `@RequiredArgsConstructor` and removal of the two injection-only constructors while positively checking the deliberately retained records, exception constructor, and presentation method. A temporary JUnit source test established RED and passed GREEN, then was removed because retaining exact source-string assertions would couple the suite to implementation details. Existing unit, MockMvc, and architecture tests compile and exercise the generated constructor API.
## Hand-derived expected result
`DashboardController` and `DashboardService` each contain final injected dependencies and constructors that only assign those fields, so both are eligible for `@RequiredArgsConstructor`. `DashboardAccessDeniedException(String)` must call its superclass and exposes a documented public error contract. The dashboard projections are already concise immutable records. `AttendanceState.label()` intentionally exposes a fluent presentation API rather than Lombok's default `getLabel()` shape.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ReportingLombokBoilerplateTest test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
ReportingLombokBoilerplateTest failed because DashboardController did not contain
import lombok.RequiredArgsConstructor; and still had its handwritten injection constructor.
BUILD FAILURE
Total time: 4.638 s
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ReportingLombokBoilerplateTest test
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 4.528 s
test "$(rg -l '@RequiredArgsConstructor' \
src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java \
src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java | wc -l | tr -d ' ')" = "2"
! rg -n 'public (DashboardController|DashboardService)\\(' \
src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java \
src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java
```
**Observed result**
```text
The temporary source audit passed 1/1 after both conversions. The final shell source audit exited 0: both eligible classes carry `@RequiredArgsConstructor`, and neither handwritten injection-only constructor remains.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=ReportingArchitectureTest,DashboardServiceTest,DashboardControllerWebTest test
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 5.952 s
./mvnw -Dtest=ReportingArchitectureTest,AccountTemplateIntegrationTest,AdminDashboardWebTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,ProjectTaskShellContractTest,RoleDashboardWebIntegrationTest,SharedErrorTemplateWebTest,DashboardServiceTest test
PostgreSQL 18.4 via Testcontainers
Tests run: 42, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 20.781 s
./mvnw -Dtest=LayerStructureTest,ReportingArchitectureTest test
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 2.198 s
./mvnw -DskipTests compile
BUILD SUCCESS
Total time: 0.655 s
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS
Total time: 1.208 s
```
## External-test boundaries
The source audit proves annotation scope and deliberate retention but does not alone prove generated bytecode, Spring injection, dashboard behavior, or Thymeleaf property access. Compile, Reporting service tests, MockMvc dashboard tests, architecture tests, PostgreSQL role-dashboard integration, and Javadoc/doclint provide those gates. Reporting owns no JPA entity or mutable bean, so JPA accessor/mapping checks are outside this feature's retrofit scope. Java 25 reports Lombok's known `sun.misc.Unsafe` annotation-processor warning, and Mockito reports its dynamic-agent warning; neither changed or failed the executed gates.
@@ -0,0 +1,93 @@
# Test Evidence: Task Lombok boilerplate retrofit
- **Test type:** Unit
- **Requirement IDs:** `AUTH-011`, `TSK-001``TSK-012`, `DB-004`
- **Scenario IDs:** `AC-TSK-001``AC-TSK-006`, `AC-TSK-010`
- **Test class/method:** Source audit plus existing Task unit, web, and PostgreSQL integration suites
- **Implementation commit:** `7c1a26d`
## Protected behavior
The Task feature uses the configured Lombok processor for mechanical dependency-injection constructors, JPA no-argument constructors, and existing entity getters. Explicit Task constructors and mutation methods remain responsible for initial state, attribution, timestamps, and workflow invariants. The retrofit must not add entity equality, hash, string, or setter behavior and must not alter the existing public getter contract.
## Test method
The source audit counts the eligible handwritten constructors and getter methods before and after the retrofit. Existing Task tests then exercise Spring injection, MVC binding, JPA materialization, entity getters, authorization, status transitions, comments, and Project/attendance boundaries through the same public behavior used before the source-only change.
## Hand-derived expected result
Four Spring components have injection-only constructors, so all four may use `@RequiredArgsConstructor`. `Task` and `TaskComment` need protected JPA no-argument constructors and may use targeted Lombok generation. The 17 existing entity getters may be generated, but `Task.deletedByMembershipId`, `Task.updatedAt`, and `Task.version` must remain without newly exposed getters. Domain constructors and `Task.changeStatus` must remain explicit.
## RED
**Command**
```text
task_component_boilerplate=$(rg -n 'public (TaskController|TaskService|TaskQueryService|TaskDashboardService)\(' src/main/java/com/lab/labtimesheet/feature/task | wc -l | tr -d ' ')
task_entity_boilerplate=$(rg -n 'protected (Task|TaskComment)\(\)|public (Long|long|String|Instant|LocalDate|TaskStatus) get[A-Z][A-Za-z0-9]*\(\)' src/main/java/com/lab/labtimesheet/feature/task/model/entity | wc -l | tr -d ' ')
printf 'component_boilerplate=%s entity_boilerplate=%s\n' "$task_component_boilerplate" "$task_entity_boilerplate"
test "$task_component_boilerplate" -eq 0 -a "$task_entity_boilerplate" -eq 0
```
**Observed result**
```text
component_boilerplate=4 entity_boilerplate=19
Exit status 1. The Task package still contained all eligible handwritten boilerplate.
```
## GREEN
**Command**
```text
task_component_boilerplate=$(rg -n 'public (TaskController|TaskService|TaskQueryService|TaskDashboardService)\(' src/main/java/com/lab/labtimesheet/feature/task | wc -l | tr -d ' ')
task_entity_boilerplate=$(rg -n 'protected (Task|TaskComment)\(\)|public (Long|long|String|Instant|LocalDate|TaskStatus) get[A-Z][A-Za-z0-9]*\(\)' src/main/java/com/lab/labtimesheet/feature/task/model/entity | wc -l | tr -d ' ')
printf 'component_boilerplate=%s entity_boilerplate=%s\n' "$task_component_boilerplate" "$task_entity_boilerplate"
test "$task_component_boilerplate" -eq 0 -a "$task_entity_boilerplate" -eq 0
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests compile
javap -classpath target/classes -p \
com.lab.labtimesheet.feature.task.model.entity.Task \
com.lab.labtimesheet.feature.task.model.entity.TaskComment \
com.lab.labtimesheet.feature.task.service.TaskService \
com.lab.labtimesheet.feature.task.service.TaskQueryService \
com.lab.labtimesheet.feature.task.service.TaskDashboardService \
com.lab.labtimesheet.feature.task.controller.TaskController
```
**Observed result**
```text
component_boilerplate=0 entity_boilerplate=0
Maven compile: BUILD SUCCESS; 127 production source files compiled with Java 25.
Bytecode inspection retained the four public component constructors, both protected JPA constructors, and all 17 existing entity getters. No getter exists for deletedByMembershipId, updatedAt, or version; domain constructors and Task.changeStatus remain explicit.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest='TaskDomainRulesTest,TaskPersistenceStructureTest,TaskQueryServiceTest,TaskDashboardServiceTest' test
# BUILD SUCCESS: 26 tests, 0 failures, 0 errors, 0 skipped.
./mvnw -Dtest='TaskControllerTest,ProjectTaskShellContractTest,ProjectTaskFormAccessibilityWebTest' test
# BUILD SUCCESS: 28 tests, 0 failures, 0 errors, 0 skipped.
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='TaskCreationIntegrationTest,TaskMutationBoundaryTest' test
# BUILD SUCCESS against PostgreSQL 18.4: 16 tests, 0 failures, 0 errors, 0 skipped.
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
# BUILD SUCCESS. Existing warnings were outside feature.task; the Task package emitted no warning.
```
## External-test boundaries
This source audit does not prove runtime behavior by itself. The affected Task tests and compilation/Javadoc gates cover the behavior-preserving contract; no browser walkthrough or production database is required because templates, mappings, schema, and business logic are unchanged. Unprivileged sandbox attempts could not attach Mockito's Java agent or reach the host Docker socket; the same commands passed outside that sandbox, with the verified OrbStack socket supplied for Testcontainers.
+81
View File
@@ -0,0 +1,81 @@
# Test Evidence: Fresh-install root navigation
- **Test type:** Web
- **Requirement IDs:** `ACC-001`
- **Scenario IDs:** `N/A — user-reported fresh-install navigation regression`
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden`
- **Implementation commit:** `531c6078521341b156d69cb1013e54f65c092311`
## Protected behavior
Before the first Admin exists, opening `/` redirects to the public one-time
bootstrap workflow instead of rendering a Whitelabel 404 page. Other protected
application routes remain concealed with HTTP 404 until bootstrap completes.
## Test method
The production Spring Security and bootstrap filter chain runs against a fresh
PostgreSQL 18.4 Testcontainer. MockMvc requests `/bootstrap`, health, `/`, and
`/dashboard`, then verifies that only the root receives the new navigation
redirect while the protected dashboard remains hidden.
## Hand-derived expected result
On an uninitialized installation, GET `/` returns a 3xx response with Location
`/bootstrap`. GET `/dashboard` still returns 404. The bootstrap form and health
endpoint remain available.
## RED
**Command**
```text
export JAVA_HOME=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden test
```
**Observed result**
```text
GET / returned 404.
Expected a 3xx redirect to /bootstrap.
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
./mvnw -Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=BootstrapIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,SecurityResponseIntegrationTest test
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
MockMvc verifies server routing, security, and persistence-backed initialization
state. It does not prove browser rendering or exercise the user's IntelliJ-run
process. The existing browser screenshot independently established the original
Whitelabel 404 symptom.
@@ -2,21 +2,17 @@ package com.lab.labtimesheet.config;
import java.util.Base64;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Security material used to encrypt integration credentials at rest. */
@ConfigurationProperties("lab.security")
@Getter
@Setter
public class SecurityProperties {
private String masterKey;
public String getMasterKey() {
return masterKey;
}
public void setMasterKey(String masterKey) {
this.masterKey = masterKey;
}
/**
* Decodes and validates the configured AES-256 master key.
*
@@ -6,6 +6,8 @@ import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
import com.lab.labtimesheet.feature.account.service.AccountService;
import jakarta.validation.Valid;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
@@ -20,13 +22,10 @@ import org.springframework.web.bind.annotation.PostMapping;
* constraints are mapped to their owning form fields without exposing persistence diagnostics.
*/
@Controller
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class AccountController {
private final AccountService accounts;
AccountController(AccountService accounts) {
this.accounts = accounts;
}
@GetMapping("/admin/accounts/new")
String newAccount(Model model) {
if (!model.containsAttribute("accountForm")) {
@@ -7,26 +7,19 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Hides all non-bootstrap application routes until durable first-Admin initialization completes.
* Only bootstrap pages, health, public assets, and error rendering remain reachable beforehand.
*/
@RequiredArgsConstructor
public class BootstrapAccessFilter extends OncePerRequestFilter {
private final BootstrapService bootstrap;
/**
* Creates the pre-bootstrap access guard.
*
* @param bootstrap durable installation-state service
*/
public BootstrapAccessFilter(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
/**
* Returns HTTP 404 for hidden routes before bootstrap so no authentication surface is exposed prematurely.
* Redirects the installation root to bootstrap and returns HTTP 404 for every other hidden route.
*
* @param request current HTTP request
* @param response current HTTP response
@@ -38,6 +31,10 @@ public class BootstrapAccessFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String path = request.getRequestURI();
if (!bootstrap.isInitialized() && path.equals(request.getContextPath() + "/")) {
response.sendRedirect(request.getContextPath() + "/bootstrap");
return;
}
if (!bootstrap.isInitialized() && !allowedBeforeBootstrap(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
@@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.account.controller;
import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.validation.Valid;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -16,13 +18,10 @@ import org.springframework.web.server.ResponseStatusException;
/** Renders and processes the one-time first-Admin installation form. */
@Controller
@RequestMapping("/bootstrap")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class BootstrapController {
private final BootstrapService bootstrap;
BootstrapController(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@GetMapping
String form(Model model) {
requireOpen();
@@ -3,10 +3,14 @@ package com.lab.labtimesheet.feature.account.model.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* Validated activation submission. Password fields remain request-local and are never repopulated by the view.
*/
@Getter
@Setter
public class ActivationForm {
@NotBlank(message = "This activation link is invalid or no longer usable")
private String token;
@@ -34,10 +38,4 @@ public class ActivationForm {
confirmPassword = null;
}
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getConfirmPassword() { return confirmPassword; }
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
}
@@ -3,11 +3,14 @@ package com.lab.labtimesheet.feature.account.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* Validated browser input for creating the first administrator.
* The password is deliberately never copied into redirected state or repopulated after validation failure.
*/
@Getter
public class BootstrapForm {
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@@ -20,29 +23,15 @@ public class BootstrapForm {
@NotBlank(message = "Password is required")
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
@Setter
private String password;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email == null ? null : email.trim();
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName == null ? null : displayName.trim();
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
@@ -8,9 +8,12 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import org.springframework.format.annotation.DateTimeFormat;
/** Validated, non-secret Admin input for creating an immutable-role account. */
@Getter
public class CreateAccountForm {
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@@ -22,15 +25,19 @@ public class CreateAccountForm {
private String displayName;
@NotNull(message = "Role is required")
@Setter
private GlobalRole role;
@Size(max = 64, message = "Student code must contain at most 64 characters")
@Setter
private String studentCode;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Setter
private LocalDate internshipStart;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Setter
private LocalDate internshipEnd;
/**
@@ -67,16 +74,6 @@ public class CreateAccountForm {
return hasText(value) ? value.trim() : null;
}
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email == null ? null : email.trim(); }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName == null ? null : displayName.trim(); }
public GlobalRole getRole() { return role; }
public void setRole(GlobalRole role) { this.role = role; }
public String getStudentCode() { return studentCode; }
public void setStudentCode(String studentCode) { this.studentCode = studentCode; }
public LocalDate getInternshipStart() { return internshipStart; }
public void setInternshipStart(LocalDate internshipStart) { this.internshipStart = internshipStart; }
public LocalDate getInternshipEnd() { return internshipEnd; }
public void setInternshipEnd(LocalDate internshipEnd) { this.internshipEnd = internshipEnd; }
}
@@ -0,0 +1,21 @@
package com.lab.labtimesheet.feature.account.model.dto;
import java.time.LocalDate;
/**
* Immutable non-secret selection data for an eligible Intern.
* The numeric user ID is the internal form submission identity; displayed fields are not authorization identifiers.
*
* @param userId persistent account identifier submitted by a consuming form
* @param displayName user-facing Intern name
* @param studentCode university student code shown to distinguish Interns
* @param internshipStart inclusive internship eligibility start date
* @param internshipEnd inclusive internship eligibility end date
*/
public record EligibleInternOption(
long userId,
String displayName,
String studentCode,
LocalDate internshipStart,
LocalDate internshipEnd) {
}
@@ -16,6 +16,9 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic
@@ -23,29 +26,37 @@ import jakarta.persistence.Version;
*/
@Entity
@Table(name = "app_users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Column(nullable = false, length = 320)
@Getter
private String email;
@Column(name = "display_name", nullable = false, length = 120)
@Getter
private String displayName;
@Column(name = "password_hash", length = 255)
@Getter
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
@Getter
private GlobalRole globalRole;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", nullable = false, length = 32)
@Getter
private AccountStatus accountStatus;
@Column(name = "activated_at")
@Getter
private Instant activatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@@ -61,10 +72,6 @@ public class AppUser {
@Version
private long version;
/** Required by JPA; domain instances are created through named factories. */
protected AppUser() {
}
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
this.email = email;
@@ -124,31 +131,4 @@ public class AppUser {
updatedAt = now;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public GlobalRole getGlobalRole() {
return globalRole;
}
public AccountStatus getAccountStatus() {
return accountStatus;
}
public Instant getActivatedAt() {
return activatedAt;
}
}
@@ -11,6 +11,9 @@ import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persistent internship lifecycle and inclusive eligibility dates for an Intern account.
@@ -18,6 +21,7 @@ import jakarta.persistence.Version;
*/
@Entity
@Table(name = "intern_profiles")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class InternProfile {
@Id
@Column(name = "user_id")
@@ -33,13 +37,16 @@ public class InternProfile {
private String phone;
@Column(name = "internship_start_date", nullable = false)
@Getter
private LocalDate internshipStartDate;
@Column(name = "internship_end_date", nullable = false)
@Getter
private LocalDate internshipEndDate;
@Enumerated(EnumType.STRING)
@Column(name = "internship_status", nullable = false, length = 24)
@Getter
private InternshipStatus internshipStatus;
@Column(name = "activated_at")
@@ -60,10 +67,6 @@ public class InternProfile {
@Version
private long version;
/** Required by JPA; domain instances are created through {@link #notStarted}. */
protected InternProfile() {
}
private InternProfile(
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
this.userId = userId;
@@ -105,15 +108,4 @@ public class InternProfile {
updatedAt = now;
}
public InternshipStatus getInternshipStatus() {
return internshipStatus;
}
public LocalDate getInternshipStartDate() {
return internshipStartDate;
}
public LocalDate getInternshipEndDate() {
return internshipEndDate;
}
}
@@ -10,16 +10,21 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
@Entity
@Table(name = "system_state")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SystemState {
@Id
@Column(name = "singleton_id")
private short singletonId;
@Column(nullable = false)
@Getter
private boolean initialized;
@Column(name = "initialized_at")
@@ -38,14 +43,6 @@ public class SystemState {
@Version
private long version;
/** Required by JPA; Flyway creates the singleton row. */
protected SystemState() {
}
public boolean isInitialized() {
return initialized;
}
/**
* Marks the installation initialized and retains the first Admin attribution.
*
@@ -12,6 +12,9 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw
@@ -19,28 +22,35 @@ import jakarta.persistence.Table;
*/
@Entity
@Table(name = "user_action_tokens")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserActionToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Column(name = "user_id", nullable = false)
@Getter
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
@Getter
private TokenPurpose purpose;
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
private byte[] tokenHash;
@Column(name = "expires_at", nullable = false)
@Getter
private Instant expiresAt;
@Column(name = "used_at")
@Getter
private Instant usedAt;
@Column(name = "invalidated_at")
@Getter
private Instant invalidatedAt;
@Column(name = "issued_by_user_id")
@@ -49,10 +59,6 @@ public class UserActionToken {
@Column(name = "created_at", nullable = false)
private Instant createdAt;
/** Required by JPA; domain instances are created through named factories. */
protected UserActionToken() {
}
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
this.userId = userId;
this.purpose = TokenPurpose.ACTIVATION;
@@ -115,18 +121,6 @@ public class UserActionToken {
}
}
public Long getId() {
return id;
}
public Long getUserId() {
return userId;
}
public TokenPurpose getPurpose() {
return purpose;
}
/**
* Returns a defensive copy of the persisted token hash.
*
@@ -136,15 +130,4 @@ public class UserActionToken {
return Arrays.copyOf(tokenHash, tokenHash.length);
}
public Instant getExpiresAt() {
return expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public Instant getInvalidatedAt() {
return invalidatedAt;
}
}
@@ -1,8 +1,12 @@
package com.lab.labtimesheet.feature.account.repository;
import java.time.LocalDate;
import java.util.List;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -19,6 +23,34 @@ public interface InternProfileRepository extends JpaRepository<InternProfile, Lo
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
/**
* Projects account-owned non-secret selection data for Interns eligible on one inclusive business date.
* Results are ordered by display name, student code, then user ID for deterministic form rendering.
*
* @param globalRole required immutable Intern role
* @param accountStatus required active account state
* @param internshipStatus required active internship state
* @param businessDate date that must fall within the inclusive internship range
* @return eligible Intern selection projections without duplicate profile rows
*/
@Query("""
select new com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption(
u.id, u.displayName, p.studentCode, p.internshipStartDate, p.internshipEndDate)
from InternProfile p
join AppUser u on u.id = p.userId
where u.globalRole = :globalRole
and u.accountStatus = :accountStatus
and p.internshipStatus = :internshipStatus
and p.internshipStartDate <= :businessDate
and p.internshipEndDate >= :businessDate
order by u.displayName asc, p.studentCode asc, u.id asc
""")
List<EligibleInternOption> findEligibleInternOptions(
@Param("globalRole") GlobalRole globalRole,
@Param("accountStatus") AccountStatus accountStatus,
@Param("internshipStatus") InternshipStatus internshipStatus,
@Param("businessDate") LocalDate businessDate);
/** Counts Intern profiles in a lifecycle state. */
long countByInternshipStatus(InternshipStatus status);
@@ -8,6 +8,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
@@ -17,6 +18,7 @@ import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
@@ -238,6 +240,24 @@ public class AccountService {
.isPresent();
}
/**
* Lists non-secret Intern selection options eligible on an explicit business date.
* The result requires active account and internship states plus inclusive internship dates, but it does not
* authorize a consuming Project operation; that operation must recheck its own ownership and membership rules.
*
* @param businessDate server-derived business date to evaluate inclusively
* @return deterministic options ordered by display name, student code, then account ID
* @throws IllegalArgumentException when {@code businessDate} is {@code null}
*/
@Transactional(readOnly = true)
public List<EligibleInternOption> eligibleInternOptions(LocalDate businessDate) {
if (businessDate == null) {
throw new IllegalArgumentException("Business date is required");
}
return internProfiles.findEligibleInternOptions(
GlobalRole.INTERN, AccountStatus.ACTIVE, InternshipStatus.ACTIVE, businessDate);
}
/**
* Resolves the cross-feature identity of a currently eligible Intern.
*
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -16,20 +18,13 @@ import org.springframework.transaction.annotation.Transactional;
* Successful creation persists the first active Admin and initialization marker atomically.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class BootstrapService {
private final SystemStateRepository systemStates;
private final AppUserRepository users;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
Clock clock) {
this.systemStates = systemStates;
this.users = users;
this.passwords = passwords;
this.clock = clock;
}
/**
* Creates the first active Admin exactly once.
*
@@ -2,6 +2,8 @@ package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -11,13 +13,10 @@ import org.springframework.transaction.annotation.Transactional;
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
/**
* Loads the normalized account and disables authentication unless its lifecycle state is active.
*
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServ
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import java.security.Principal;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
@@ -22,17 +24,12 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
*/
@Controller
@RequestMapping("/attendance")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class AttendanceController {
private final AttendanceApplicationService attendance;
private final AttendanceCurrentUserService currentUsers;
AttendanceController(
AttendanceApplicationService attendance, AttendanceCurrentUserService currentUsers) {
this.attendance = attendance;
this.currentUsers = currentUsers;
}
/**
* Renders the authenticated Intern's inclusive attendance history, defaulting to the current month.
*
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserServ
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import java.security.Principal;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
@@ -23,21 +25,13 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
*/
@Controller
@RequestMapping("/attendance/calendar")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class CalendarController {
private final CalendarApplicationService calendar;
private final AttendanceApplicationService attendance;
private final AttendanceCurrentUserService currentUsers;
CalendarController(
CalendarApplicationService calendar,
AttendanceApplicationService attendance,
AttendanceCurrentUserService currentUsers) {
this.calendar = calendar;
this.attendance = attendance;
this.currentUsers = currentUsers;
}
/**
* Renders the next year of locally stored calendar events for an authenticated Admin.
*
@@ -19,12 +19,15 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA mapping of an immutable-on-effective attendance policy version and its configured workdays.
*/
@Entity
@Table(name = "attendance_policy_versions")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AttendancePolicyEntity {
@Id
@@ -65,11 +68,6 @@ public class AttendancePolicyEntity {
@Version
private long version;
/**
* Required by JPA; application code resolves existing effective-dated versions instead of constructing them here.
*/
protected AttendancePolicyEntity() {}
/**
* Converts the persisted version to the immutable policy used for historical boundary calculations.
*
@@ -13,12 +13,15 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA persistence model for one Intern/work-date punch row with its permanently attached policy version.
*/
@Entity
@Table(name = "attendance_records")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AttendanceRecordEntity {
@Id
@@ -44,11 +47,6 @@ public class AttendanceRecordEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected AttendanceRecordEntity() {}
/**
* Creates a new persistence row from server-authoritative raw punch values.
*
@@ -10,12 +10,15 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA model for the locally authoritative global calendar decision.
*/
@Entity
@Table(name = "global_calendar_events")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GlobalCalendarEventEntity {
@Id
@@ -43,11 +46,6 @@ public class GlobalCalendarEventEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected GlobalCalendarEventEntity() {}
/**
* Creates a custom calendar event attributed to the Admin actor.
*
@@ -9,12 +9,15 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA mapping of an immutable leave-day allocation whose exact date, policy, and quota snapshot remain historical.
*/
@Entity
@Table(name = "leave_request_days")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LeaveRequestDayEntity {
@EmbeddedId
@@ -35,11 +38,6 @@ public class LeaveRequestDayEntity {
@Column(name = "monthly_quota_snapshot", nullable = false)
private int monthlyQuotaSnapshot;
/**
* Required by JPA.
*/
protected LeaveRequestDayEntity() {}
LeaveRequestDayEntity(
LeaveRequestEntity request,
LocalDate leaveDate,
@@ -5,11 +5,14 @@ import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Composite identifier of one frozen quota-consuming date within a leave request.
*/
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LeaveRequestDayId implements Serializable {
/** Parent request identity used by the composite primary key. */
@@ -20,11 +23,6 @@ public class LeaveRequestDayId implements Serializable {
@Column(name = "leave_date", nullable = false)
private LocalDate leaveDate;
/**
* Required by JPA.
*/
protected LeaveRequestDayId() {}
/**
* Creates the identity for an already-persisted request and its exact allocated date.
*
@@ -9,12 +9,15 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations.
*/
@Entity
@Table(name = "leave_requests")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LeaveRequestEntity {
@Id
@@ -51,11 +54,6 @@ public class LeaveRequestEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected LeaveRequestEntity() {}
LeaveRequestEntity(
long internUserId,
LocalDate startDate,
@@ -20,9 +20,11 @@ import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.security.access.AccessDeniedException;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +33,7 @@ import org.springframework.transaction.annotation.Transactional;
* Account eligibility is obtained only through {@link AccountService}; raw rows retain their attached policy.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class AttendanceApplicationService {
private final Clock clock;
@@ -41,23 +44,6 @@ public class AttendanceApplicationService {
private final CalendarApplicationService calendar;
private final AttendanceService attendance;
AttendanceApplicationService(
Clock clock,
AttendancePolicyRepository policyEntities,
AttendanceRecordRepository recordEntities,
AttendanceQueryRepository queries,
AccountService accounts,
CalendarApplicationService calendar,
AttendanceService attendance) {
this.clock = clock;
this.policyEntities = policyEntities;
this.recordEntities = recordEntities;
this.queries = queries;
this.accounts = accounts;
this.calendar = calendar;
this.attendance = attendance;
}
/**
* Records the sole server-time check-in for the effective policy-local date.
* Eligibility, workday, calendar, and exact frozen leave allocation are evaluated in the transaction;
@@ -6,6 +6,8 @@ import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import java.security.Principal;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
@@ -13,14 +15,11 @@ import org.springframework.stereotype.Service;
* Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class AttendanceCurrentUserService {
private final AccountService accounts;
AttendanceCurrentUserService(AccountService accounts) {
this.accounts = accounts;
}
/**
* Resolves the authenticated email through the Account feature and rejects missing or inactive identities.
*
@@ -8,16 +8,17 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Service;
/**
* Pure attendance punch rules over immutable policy, date context, and raw record values.
*/
@Service
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public final class AttendanceService {
AttendanceService() {}
/**
* Creates the sole raw check-in for an eligible Intern/date using the supplied server instant.
* Equality at the grace boundary is accepted; violation classification remains attached-policy based.
@@ -12,6 +12,8 @@ import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRep
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,21 +22,13 @@ import org.springframework.transaction.annotation.Transactional;
* Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class CalendarApplicationService {
private final Clock clock;
private final AttendancePolicyRepository policies;
private final GlobalCalendarEventRepository events;
CalendarApplicationService(
Clock clock,
AttendancePolicyRepository policies,
GlobalCalendarEventRepository events) {
this.clock = clock;
this.policies = policies;
this.events = events;
}
/**
* Creates an Admin-authored custom event on a non-past policy-local date.
*
@@ -9,6 +9,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.MailException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
*/
@Controller
@RequestMapping("/admin/smtp")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class SmtpController {
private static final String TEST_FAILURE_MESSAGE =
"SMTP test failed. Verify the draft settings and server availability, then try again.";
@@ -41,11 +44,6 @@ class SmtpController {
private final SmtpConfigurationService smtp;
private final AccountService accounts;
SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
this.smtp = smtp;
this.accounts = accounts;
}
@GetMapping
String form(Model model) {
return renderForm(model, null);
@@ -1,6 +1,8 @@
package com.lab.labtimesheet.feature.integration.controller;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -9,13 +11,10 @@ import org.springframework.web.bind.annotation.ModelAttribute;
* configuration is active.
*/
@ControllerAdvice
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class SmtpWarningAdvice {
private final SmtpConfigurationService smtp;
SmtpWarningAdvice(SmtpConfigurationService smtp) {
this.smtp = smtp;
}
@ModelAttribute("smtpRestricted")
boolean smtpRestricted() {
return !smtp.hasActiveConfiguration();
@@ -2,18 +2,15 @@ package com.lab.labtimesheet.feature.integration.model.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.Setter;
/** Validated identifier submitted by the SMTP test and activation forms. */
@Getter
@Setter
public class SmtpActionForm {
@NotNull(message = "SMTP draft is required")
@Positive(message = "SMTP draft is invalid")
private Long draftId;
public Long getDraftId() {
return draftId;
}
public void setDraftId(Long draftId) {
this.draftId = draftId;
}
}
@@ -8,11 +8,15 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* Validated Admin input for an SMTP draft. The cleartext password exists only for the current request and is
* cleared before the form is rendered again.
*/
@Getter
@Setter
public class SmtpForm {
@NotBlank(message = "Host is required")
@Size(max = 255, message = "Host must contain at most 255 characters")
@@ -96,18 +100,4 @@ public class SmtpForm {
return value == null || value.isEmpty() ? null : value;
}
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public SecurityMode getSecurityMode() { return securityMode; }
public void setSecurityMode(SecurityMode securityMode) { this.securityMode = securityMode; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getFromAddress() { return fromAddress; }
public void setFromAddress(String fromAddress) { this.fromAddress = fromAddress; }
public String getFromName() { return fromName; }
public void setFromName(String fromName) { this.fromName = fromName; }
}
@@ -15,6 +15,9 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Versioned SMTP configuration entity whose credentials remain AES-GCM encrypted at rest.
@@ -22,26 +25,33 @@ import jakarta.persistence.Version;
*/
@Entity
@Table(name = "smtp_configurations")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SmtpConfiguration {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
@Getter
private SmtpStatus status;
@Column(nullable = false, length = 255)
@Getter
private String host;
@Column(nullable = false)
@Getter
private int port;
@Enumerated(EnumType.STRING)
@Column(name = "security_mode", nullable = false, length = 16)
@Getter
private SecurityMode securityMode;
@Column(length = 320)
@Getter
private String username;
@Column(name = "password_ciphertext")
@@ -51,15 +61,19 @@ public class SmtpConfiguration {
private byte[] passwordNonce;
@Column(name = "secret_key_version")
@Getter
private Integer secretKeyVersion;
@Column(name = "from_address", nullable = false, length = 320)
@Getter
private String fromAddress;
@Column(name = "from_name", nullable = false, length = 120)
@Getter
private String fromName;
@Column(name = "tested_at")
@Getter
private Instant testedAt;
@Column(name = "tested_by_user_id")
@@ -89,10 +103,6 @@ public class SmtpConfiguration {
@Version
private long version;
/** Required by JPA; revisions are created through {@link #draft}. */
protected SmtpConfiguration() {
}
/**
* Creates an editable SMTP revision with encrypted credential material.
*
@@ -191,30 +201,6 @@ public class SmtpConfiguration {
return value == null || value.isBlank() ? null : value.trim();
}
public Long getId() {
return id;
}
public SmtpStatus getStatus() {
return status;
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public SecurityMode getSecurityMode() {
return securityMode;
}
public String getUsername() {
return username;
}
/** @return a defensive copy of encrypted password bytes, or {@code null} */
public byte[] getPasswordCiphertext() {
return passwordCiphertext == null ? null : passwordCiphertext.clone();
@@ -225,19 +211,4 @@ public class SmtpConfiguration {
return passwordNonce == null ? null : passwordNonce.clone();
}
public Integer getSecretKeyVersion() {
return secretKeyVersion;
}
public String getFromAddress() {
return fromAddress;
}
public String getFromName() {
return fromName;
}
public Instant getTestedAt() {
return testedAt;
}
}
@@ -4,6 +4,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -12,17 +14,12 @@ import org.springframework.transaction.annotation.Transactional;
* Stored credentials are decrypted only while constructing the immediate adapter call.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class MailDeliveryService {
private final SmtpConfigurationRepository configurations;
private final SecretCipher secrets;
private final SmtpProbe probe;
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
this.configurations = configurations;
this.secrets = secrets;
this.probe = probe;
}
/**
* Reports whether workflows may emit required email.
*
@@ -11,6 +11,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpSetupStatus;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
@@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
* A changed draft loses prior test status, and an active revision is retired when its tested successor activates.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class SmtpConfigurationService {
private final SmtpConfigurationRepository configurations;
private final AccountService accounts;
@@ -30,18 +33,6 @@ public class SmtpConfigurationService {
private final Clock clock;
private final MailDeliveryService mailDelivery;
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
MailDeliveryService mailDelivery) {
this.configurations = configurations;
this.accounts = accounts;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
this.mailDelivery = mailDelivery;
}
/**
* Creates or replaces the editable draft after validating Admin authority and environment transport rules.
* Any supplied password is encrypted before persistence and prior test status is cleared.
@@ -8,6 +8,7 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import jakarta.validation.Valid;
import java.security.Principal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
@@ -27,22 +28,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
*/
@Controller
@RequestMapping("/projects")
@RequiredArgsConstructor
public class ProjectController {
private final ProjectQueryService pages;
private final ProjectService projects;
/**
* Creates the MVC adapter for Project queries and mutations.
*
* @param pages authorized Project read operations
* @param projects transactional Project mutation operations
*/
public ProjectController(ProjectQueryService pages, ProjectService projects) {
this.pages = pages;
this.projects = projects;
}
/**
* Lists only Projects visible to the authenticated actor and exposes Project creation only
* to Mentors.
@@ -17,10 +17,6 @@ import org.springframework.web.servlet.ModelAndView;
@ControllerAdvice(assignableTypes = ProjectController.class)
public class ProjectControllerAdvice {
/** Creates the stateless Project exception-to-view adapter. */
public ProjectControllerAdvice() {
}
/**
* Hides whether a requested Project or nested resource exists.
*
@@ -22,6 +22,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA aggregate root for Project lifecycle, membership intervals, and leadership intervals.
@@ -32,6 +34,7 @@ import java.util.Set;
*/
@Entity
@Table(name = "projects")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectEntity {
@Id
@@ -75,10 +78,6 @@ public class ProjectEntity {
@Version
private long version;
/** Constructor reserved for JPA materialization. */
protected ProjectEntity() {
}
private ProjectEntity(
long mentorUserId,
String name,
@@ -11,6 +11,8 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA leadership interval attached to an active same-Project membership.
@@ -20,6 +22,7 @@ import java.time.Instant;
*/
@Entity
@Table(name = "project_leadership_terms")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectLeadershipTermEntity {
@Id
@@ -46,10 +49,6 @@ public class ProjectLeadershipTermEntity {
@Column(name = "ended_by_mentor_user_id")
private Long endedByMentorUserId;
/** Constructor reserved for JPA materialization. */
protected ProjectLeadershipTermEntity() {
}
ProjectLeadershipTermEntity(
ProjectEntity project,
ProjectMembershipEntity membership,
@@ -11,6 +11,8 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA membership interval linking one Intern to one Project.
@@ -20,6 +22,7 @@ import java.time.Instant;
*/
@Entity
@Table(name = "project_memberships")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectMembershipEntity {
@Id
@@ -48,10 +51,6 @@ public class ProjectMembershipEntity {
@Version
private long version;
/** Constructor reserved for JPA materialization. */
protected ProjectMembershipEntity() {
}
ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) {
this.project = project;
this.internUserId = internUserId;
@@ -15,6 +15,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -26,22 +27,12 @@ import org.springframework.transaction.annotation.Transactional;
* members but expose no current Leader or active-member context.
*/
@Service
@RequiredArgsConstructor
public class ProjectQueryService {
private final ProjectRepository projects;
private final AccountService accounts;
/**
* Creates the Project read service.
*
* @param projects Project aggregate repository
* @param accounts public Account identity and internship-eligibility boundary
*/
public ProjectQueryService(ProjectRepository projects, AccountService accounts) {
this.projects = projects;
this.accounts = accounts;
}
/**
* Resolves an active authenticated account to its stable user identifier.
*
@@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -22,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
* imports their repositories or entities.
*/
@Service
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository projects;
@@ -30,28 +32,6 @@ public class ProjectService {
private final TaskQueryService taskQueries;
private final Clock clock;
/**
* Creates the Project mutation service.
*
* @param projects Project aggregate repository
* @param accounts public Account identity and eligibility boundary
* @param queries DTO-only Project query boundary reused for locked Task context
* @param taskQueries public Task activation-guard boundary
* @param clock server clock supplying persisted mutation instants
*/
public ProjectService(
ProjectRepository projects,
AccountService accounts,
ProjectQueryService queries,
TaskQueryService taskQueries,
Clock clock) {
this.projects = projects;
this.accounts = accounts;
this.queries = queries;
this.taskQueries = taskQueries;
this.clock = clock;
}
/**
* Atomically creates a planned Mentor-owned Project, eligible initial membership, and first
* leadership term. {@code saveAndFlush} exposes database invariant violations before commit.
@@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.reporting.controller;
import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException;
import com.lab.labtimesheet.feature.reporting.service.DashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -14,19 +15,11 @@ import org.springframework.web.bind.annotation.GetMapping;
* reloads and revalidates the persisted account role and lifecycle before returning any data.
*/
@Controller
@RequiredArgsConstructor
public class DashboardController {
private final DashboardService dashboardService;
/**
* Creates the dashboard endpoint backed by the reporting composition service.
*
* @param dashboardService service that authorizes and assembles role-scoped dashboard data
*/
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
/**
* Renders the dashboard permitted by the caller's authenticated global role.
*
@@ -16,6 +16,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView;
import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,6 +29,7 @@ import org.springframework.transaction.annotation.Transactional;
*/
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DashboardService {
private final AccountService accounts;
@@ -35,25 +37,6 @@ public class DashboardService {
private final TaskDashboardService tasks;
private final AttendanceApplicationService attendance;
/**
* Creates a reporting coordinator over the concrete feature query boundaries.
*
* @param accounts account identity and Admin summary boundary
* @param projects role-scoped Project summary boundary
* @param tasks role-scoped Task dashboard boundary
* @param attendance attendance state boundary using the active policy business date
*/
public DashboardService(
AccountService accounts,
ProjectQueryService projects,
TaskDashboardService tasks,
AttendanceApplicationService attendance) {
this.accounts = accounts;
this.projects = projects;
this.tasks = tasks;
this.attendance = attendance;
}
/**
* Builds system-wide Admin counts after confirming an active persisted Admin identity.
*
@@ -12,6 +12,7 @@ import com.lab.labtimesheet.feature.task.service.TaskService;
import jakarta.validation.Valid;
import java.util.Arrays;
import java.util.Locale;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -31,19 +32,11 @@ import org.springframework.web.bind.annotation.RequestParam;
* successful mutations use redirects to prevent duplicate submissions.
*/
@Controller
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
/**
* Creates the MVC adapter for the Task application service.
*
* @param taskService authorized Task use cases
*/
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping("/projects/{projectId}/tasks")
String list(Authentication authentication, @PathVariable long projectId, Model model) {
TaskListView taskList = taskService.list(authentication.getName(), projectId);
@@ -12,6 +12,9 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persisted Task aggregate row with one current same-Project assignee.
@@ -22,6 +25,8 @@ import java.time.LocalDate;
*/
@Entity
@Table(name = "tasks")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Task {
@Id
@@ -60,20 +65,20 @@ public class Task {
private Instant deletedAt;
@Column(name = "deleted_by_membership_id")
@Getter(AccessLevel.NONE)
private Long deletedByMembershipId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
@Getter(AccessLevel.NONE)
private Instant updatedAt;
@Version
@Getter(AccessLevel.NONE)
private long version;
/** Constructor reserved for JPA materialization. */
protected Task() {}
/**
* Creates a TODO Task and records the creating membership as both creator and assigner.
*
@@ -121,111 +126,4 @@ public class Task {
updatedAt = now;
}
/**
* Returns the persistence identity.
*
* @return Task identifier, or {@code null} before insertion
*/
public Long getId() {
return id;
}
/**
* Returns the aggregate identity.
*
* @return owning Project identifier
*/
public long getProjectId() {
return projectId;
}
/**
* Returns the current assignment identity.
*
* @return current same-Project assignee membership identifier
*/
public long getAssigneeMembershipId() {
return assigneeMembershipId;
}
/**
* Returns the display title.
*
* @return normalized Task title
*/
public String getTitle() {
return title;
}
/**
* Returns the descriptive text.
*
* @return optional normalized description
*/
public String getDescription() {
return description;
}
/**
* Returns the workflow state.
*
* @return current fixed workflow status
*/
public TaskStatus getStatus() {
return status;
}
/**
* Returns the business deadline.
*
* @return optional validated due date
*/
public LocalDate getDueDate() {
return dueDate;
}
/**
* Returns current assignment timing.
*
* @return instant when the current assignment was established
*/
public Instant getAssignedAt() {
return assignedAt;
}
/**
* Returns original creator attribution.
*
* @return immutable creating membership identifier
*/
public long getCreatorMembershipId() {
return creatorMembershipId;
}
/**
* Returns current assignment attribution.
*
* @return membership identifier responsible for the current assignment
*/
public long getAssignerMembershipId() {
return assignerMembershipId;
}
/**
* Returns lifecycle visibility state.
*
* @return soft-deletion instant, or {@code null} while current
*/
public Instant getDeletedAt() {
return deletedAt;
}
/**
* Returns creation timing.
*
* @return immutable creation instant
*/
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -7,6 +7,9 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Persisted append-only Task comment.
@@ -16,6 +19,8 @@ import java.time.Instant;
*/
@Entity
@Table(name = "task_comments")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TaskComment {
@Id
@@ -34,9 +39,6 @@ public class TaskComment {
@Column(name = "created_at", nullable = false)
private Instant createdAt;
/** Constructor reserved for JPA materialization. */
protected TaskComment() {}
/**
* Creates an immutable comment from server-authorized values.
*
@@ -52,48 +54,4 @@ public class TaskComment {
this.createdAt = createdAt;
}
/**
* Returns the persistence identity.
*
* @return comment identifier, or {@code null} before insertion
*/
public Long getId() {
return id;
}
/**
* Returns the owning record identity.
*
* @return owning Task identifier
*/
public long getTaskId() {
return taskId;
}
/**
* Returns historical authorship.
*
* @return immutable historical author user identifier
*/
public long getAuthorUserId() {
return authorUserId;
}
/**
* Returns comment content.
*
* @return normalized comment text
*/
public String getBody() {
return body;
}
/**
* Returns creation timing.
*
* @return immutable creation instant
*/
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -23,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
* priorities cover current assignments only where the actor still has an active membership.
*/
@Service
@RequiredArgsConstructor
public class TaskDashboardService {
private static final TaskDashboardView EMPTY_DASHBOARD = new TaskDashboardView(0, 0, List.of());
@@ -30,17 +32,6 @@ public class TaskDashboardService {
private final TaskRepository tasks;
private final ProjectQueryService projects;
/**
* Creates the dashboard query service.
*
* @param tasks Task persistence boundary
* @param projects authorized Project query boundary
*/
public TaskDashboardService(TaskRepository tasks, ProjectQueryService projects) {
this.tasks = tasks;
this.projects = projects;
}
/**
* Builds the role-scoped Task dashboard for one authenticated account.
*
@@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.task.service;
import com.lab.labtimesheet.feature.task.repository.TaskRepository;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -9,19 +10,11 @@ import org.springframework.transaction.annotation.Transactional;
* Public Task query boundary used by other features without exposing Task entities or repositories.
*/
@Service
@RequiredArgsConstructor
public class TaskQueryService {
private final TaskRepository tasks;
/**
* Creates the cross-feature Task query service.
*
* @param tasks Task persistence boundary
*/
public TaskQueryService(TaskRepository tasks) {
this.tasks = tasks;
}
/**
* Counts current Tasks assigned outside the supplied active membership set.
*
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,6 +43,7 @@ import org.springframework.transaction.annotation.Transactional;
* to a non-disclosing Task 404, while authenticated business-rule failures use Task validation.
*/
@Service
@RequiredArgsConstructor
public class TaskService {
private final TaskRepository tasks;
@@ -51,31 +53,6 @@ public class TaskService {
private final CalendarApplicationService calendar;
private final Clock clock;
/**
* Creates the Task application service and its feature boundaries.
*
* @param tasks Task persistence boundary
* @param comments append-only comment persistence boundary
* @param projects authorized Project read boundary
* @param projectMutations Project-first locking mutation boundary
* @param calendar authoritative global day-off query boundary
* @param clock server time source for persisted instants
*/
public TaskService(
TaskRepository tasks,
TaskCommentRepository comments,
ProjectQueryService projects,
ProjectService projectMutations,
CalendarApplicationService calendar,
Clock clock) {
this.tasks = tasks;
this.comments = comments;
this.projects = projects;
this.projectMutations = projectMutations;
this.calendar = calendar;
this.clock = clock;
}
/**
* Creates a TODO Task in a PLANNED or ACTIVE Project.
*
@@ -1,35 +0,0 @@
# Development profile. Values that differ between machines come from an untracked .env file.
server.port=${LAB_SERVER_PORT}
server.forward-headers-strategy=${LAB_FORWARD_HEADERS_STRATEGY}
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=false
server.servlet.session.cookie.same-site=lax
server.error.include-message=never
server.error.include-stacktrace=never
spring.datasource.url=${LAB_DB_URL}
spring.datasource.username=${LAB_DB_USERNAME}
spring.datasource.password=${LAB_DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.docker.compose.enabled=false
# Mailpit keeps Spring's mail health check local. User-facing SMTP credentials remain Admin-console data.
spring.mail.host=${LAB_SMTP_HOST}
spring.mail.port=${LAB_SMTP_PORT}
spring.mail.protocol=smtp
spring.mail.test-connection=false
spring.mail.properties.mail.smtp.auth=false
spring.mail.properties.mail.smtp.starttls.enable=false
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=when_authorized
lab.public-origin=${LAB_PUBLIC_ORIGIN}
lab.security.master-key=${LAB_SECURITY_MASTER_KEY}
+64
View File
@@ -0,0 +1,64 @@
# Development profile. Machine-specific values come from the ignored root .env file.
spring:
config:
import: "optional:file:${LAB_DEV_ENV_FILE:.env}[.properties]"
datasource:
url: "${LAB_DB_URL}"
username: "${LAB_DB_USERNAME}"
password: "${LAB_DB_PASSWORD}"
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway:
enabled: true
locations: classpath:db/migration
docker:
compose:
enabled: false
# Mailpit keeps Spring's mail health check local. User-facing SMTP credentials remain Admin-console data.
mail:
host: "${LAB_SMTP_HOST}"
port: "${LAB_SMTP_PORT}"
protocol: smtp
test-connection: false
properties:
mail:
smtp:
auth: false
starttls:
enable: false
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
server:
port: "${LAB_SERVER_PORT}"
forward-headers-strategy: "${LAB_FORWARD_HEADERS_STRATEGY}"
servlet:
session:
cookie:
http-only: true
secure: false
same-site: lax
error:
include-message: never
include-stacktrace: never
management:
endpoints:
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: when_authorized
lab:
public-origin: "${LAB_PUBLIC_ORIGIN}"
security:
master-key: "${LAB_SECURITY_MASTER_KEY}"
@@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.account.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.LocalDate;
@@ -57,10 +58,13 @@ class BootstrapIntegrationTest {
private DataSource dataSource;
@Test
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
void rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
mockMvc.perform(get("/")).andExpect(status().isNotFound());
mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/bootstrap"));
mockMvc.perform(get("/dashboard")).andExpect(status().isNotFound());
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
mockMvc.perform(get("/bootstrap")).andExpect(status().isNotFound());
@@ -0,0 +1,165 @@
package com.lab.labtimesheet.feature.account.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class EligibleInternOptionIntegrationTest {
private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z");
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 14);
@Autowired
private AccountService accounts;
@Autowired
private AppUserRepository users;
@Autowired
private InternProfileRepository internProfiles;
@Autowired
private JdbcTemplate jdbc;
private AppUser admin;
private int userSequence;
@BeforeEach
void setUp() {
admin = users.saveAndFlush(AppUser.bootstrapAdmin(
"picker-admin@example.com", "Picker Admin", "encoded-password", NOW));
}
@Test
void listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder() {
long lowerBoundary = activeIntern("Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10));
long upperBoundary = activeIntern("Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE);
long laterName = activeIntern("Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
pendingInternWithActiveProfile("Ignored Pending", "STU-400");
long locked = activeIntern("Ignored Locked", "STU-500", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
lock(locked);
long deactivated = activeIntern(
"Ignored Deactivated", "STU-600", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
deactivate(deactivated);
activeMentorWithActiveProfile("Ignored Mentor", "STU-700");
activeInternWithNotStartedProfile("Ignored Not Started", "STU-800");
activeIntern("Ignored Ended", "STU-900", BUSINESS_DATE.minusDays(10), BUSINESS_DATE.minusDays(1));
long completed = activeIntern(
"Ignored Completed", "STU-1000", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
completeInternship(completed);
List<EligibleInternOption> options = accounts.eligibleInternOptions(BUSINESS_DATE);
assertThat(options).containsExactly(
new EligibleInternOption(
lowerBoundary, "Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10)),
new EligibleInternOption(
upperBoundary, "Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE),
new EligibleInternOption(
laterName, "Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1)));
assertThat(options).extracting(EligibleInternOption::userId).doesNotHaveDuplicates();
}
@Test
void rejectsMissingBusinessDate() {
assertThatThrownBy(() -> accounts.eligibleInternOptions(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Business date is required");
}
private long activeIntern(String displayName, String studentCode, LocalDate startDate, LocalDate endDate) {
long userId = activeUser(GlobalRole.INTERN, displayName);
activeProfile(userId, studentCode, startDate, endDate);
return userId;
}
private void pendingInternWithActiveProfile(String displayName, String studentCode) {
long userId = pendingUser(GlobalRole.INTERN, displayName);
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
}
private void activeMentorWithActiveProfile(String displayName, String studentCode) {
long userId = activeUser(GlobalRole.MENTOR, displayName);
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
}
private void activeInternWithNotStartedProfile(String displayName, String studentCode) {
long userId = activeUser(GlobalRole.INTERN, displayName);
internProfiles.saveAndFlush(InternProfile.notStarted(
userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1), NOW));
}
private long activeUser(GlobalRole role, String displayName) {
AppUser user = AppUser.pending(nextEmail(), displayName, role, admin, NOW);
user.activate("encoded-password", NOW);
return users.saveAndFlush(user).getId();
}
private long pendingUser(GlobalRole role, String displayName) {
return users.saveAndFlush(AppUser.pending(nextEmail(), displayName, role, admin, NOW)).getId();
}
private void activeProfile(long userId, String studentCode, LocalDate startDate, LocalDate endDate) {
InternProfile profile = InternProfile.notStarted(userId, studentCode, startDate, endDate, NOW);
profile.activate(NOW);
internProfiles.saveAndFlush(profile);
}
private void lock(long userId) {
assertThat(jdbc.update(
"""
update app_users
set account_status = 'LOCKED', locked_at = ?, updated_at = ?
where id = ?
""",
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
}
private void deactivate(long userId) {
assertThat(jdbc.update(
"""
update app_users
set account_status = 'DEACTIVATED', deactivated_at = ?, updated_at = ?
where id = ?
""",
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
}
private void completeInternship(long userId) {
assertThat(jdbc.update(
"""
update intern_profiles
set internship_status = 'COMPLETED', completed_at = ?, updated_at = ?
where user_id = ?
""",
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
}
private String nextEmail() {
return "picker-" + ++userSequence + "@example.com";
}
}
@@ -0,0 +1,398 @@
package com.lab.labtimesheet.feature.attendance;
import static org.assertj.core.api.Assertions.assertThat;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.controller.AttendanceController;
import com.lab.labtimesheet.feature.attendance.controller.CalendarController;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayId;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity;
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRepository;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import com.lab.labtimesheet.feature.attendance.service.AttendanceService;
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.security.Principal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.ui.Model;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
class AttendanceLombokBoilerplateTest {
private static final int PACKAGE_PRIVATE = 0;
@Test
void generatedConstructorsPreserveParameterListsAndVisibility() {
assertConstructors(
AttendanceController.class,
constructor(
PACKAGE_PRIVATE,
AttendanceApplicationService.class,
AttendanceCurrentUserService.class));
assertConstructors(
CalendarController.class,
constructor(
PACKAGE_PRIVATE,
CalendarApplicationService.class,
AttendanceApplicationService.class,
AttendanceCurrentUserService.class));
assertConstructors(
AttendanceApplicationService.class,
constructor(
PACKAGE_PRIVATE,
Clock.class,
AttendancePolicyRepository.class,
AttendanceRecordRepository.class,
AttendanceQueryRepository.class,
AccountService.class,
CalendarApplicationService.class,
AttendanceService.class));
assertConstructors(
AttendanceCurrentUserService.class,
constructor(PACKAGE_PRIVATE, AccountService.class));
assertConstructors(
CalendarApplicationService.class,
constructor(
PACKAGE_PRIVATE,
Clock.class,
AttendancePolicyRepository.class,
GlobalCalendarEventRepository.class));
assertConstructors(AttendanceService.class, constructor(PACKAGE_PRIVATE));
assertConstructors(AttendancePolicyEntity.class, constructor(Modifier.PROTECTED));
assertConstructors(
AttendanceRecordEntity.class,
constructor(Modifier.PROTECTED),
constructor(
Modifier.PUBLIC,
long.class,
LocalDate.class,
AttendancePolicyEntity.class,
Instant.class,
Instant.class));
assertConstructors(
GlobalCalendarEventEntity.class,
constructor(Modifier.PROTECTED),
constructor(
Modifier.PUBLIC,
LocalDate.class,
String.class,
boolean.class,
long.class));
assertConstructors(
LeaveRequestDayEntity.class,
constructor(Modifier.PROTECTED),
constructor(
PACKAGE_PRIVATE,
LeaveRequestEntity.class,
LocalDate.class,
AttendancePolicyEntity.class,
int.class));
assertConstructors(
LeaveRequestDayId.class,
constructor(Modifier.PROTECTED),
constructor(Modifier.PUBLIC, long.class, LocalDate.class));
assertConstructors(
LeaveRequestEntity.class,
constructor(Modifier.PROTECTED),
constructor(
PACKAGE_PRIVATE,
long.class,
LocalDate.class,
LocalDate.class,
String.class,
Instant.class,
Instant.class,
long.class,
Instant.class));
}
@Test
void immutableModelsRemainRecordsWithTheirComponentContracts() {
assertRecordComponents(
AttendanceActor.class,
component("userId", long.class),
component("role", AttendanceRole.class));
assertRecordComponents(
AttendanceDayContext.class,
component("activeIntern", boolean.class),
component("globalDayOff", boolean.class),
component("approvedLeave", boolean.class));
assertRecordComponents(
AttendancePolicy.class,
component("id", long.class),
component("effectiveFrom", LocalDate.class),
component("zoneId", ZoneId.class),
component("scheduledStart", LocalTime.class),
component("scheduledEnd", LocalTime.class),
component("checkInGraceMinutes", int.class),
component("checkoutGraceMinutes", int.class),
component("monthlyLeaveQuota", int.class),
component("violationPenalty", BigDecimal.class),
component("workdays", Set.class));
assertRecordComponents(
AttendanceRecord.class,
component("internId", long.class),
component("workDate", LocalDate.class),
component("policy", AttendancePolicy.class),
component("checkInAt", Instant.class),
component("checkOutAt", Instant.class));
assertRecordComponents(
AttendanceViolations.class,
component("late", boolean.class),
component("earlyDeparture", boolean.class),
component("missingCheckout", boolean.class));
assertRecordComponents(
AttendanceHistoryItem.class,
component("workDate", LocalDate.class),
component("checkInAt", Instant.class),
component("checkOutAt", Instant.class),
component("policy", AttendancePolicy.class),
component("violations", AttendanceViolations.class));
assertRecordComponents(
GlobalCalendarEvent.class,
component("id", long.class),
component("date", LocalDate.class),
component("name", String.class),
component("dayOff", boolean.class),
component("version", long.class));
}
@Test
void entitiesExposeOnlyIntentionalPublicAndProtectedDeclaredMethods() {
assertMethodSurface(
AttendancePolicyEntity.class,
method(Modifier.PUBLIC, "toDomain", AttendancePolicy.class));
assertMethodSurface(
AttendanceRecordEntity.class,
method(Modifier.PUBLIC, "toDomain", AttendanceRecord.class),
method(Modifier.PUBLIC, "setCheckOutAt", void.class, Instant.class),
method(Modifier.PUBLIC, "workDate", LocalDate.class));
assertMethodSurface(
GlobalCalendarEventEntity.class,
method(
Modifier.PUBLIC,
"update",
void.class,
LocalDate.class,
String.class,
boolean.class,
long.class),
method(Modifier.PUBLIC, "toDomain", GlobalCalendarEvent.class),
method(Modifier.PUBLIC, "calendarDate", LocalDate.class),
method(Modifier.PUBLIC, "version", long.class));
assertMethodSurface(LeaveRequestDayEntity.class);
assertMethodSurface(
LeaveRequestDayId.class,
method(Modifier.PUBLIC, "equals", boolean.class, Object.class),
method(Modifier.PUBLIC, "hashCode", int.class));
assertMethodSurface(LeaveRequestEntity.class);
}
@Test
void componentsExposeOnlyIntentionalPublicAndProtectedDeclaredMethods() {
assertMethodSurface(
AttendanceController.class,
method(
Modifier.PUBLIC,
"ownHistory",
String.class,
Principal.class,
LocalDate.class,
LocalDate.class,
Model.class),
method(
Modifier.PUBLIC,
"inspectHistory",
String.class,
Principal.class,
long.class,
LocalDate.class,
LocalDate.class,
Model.class),
method(
Modifier.PUBLIC,
"checkIn",
String.class,
Principal.class,
RedirectAttributes.class),
method(
Modifier.PUBLIC,
"checkOut",
String.class,
Principal.class,
RedirectAttributes.class));
assertMethodSurface(
CalendarController.class,
method(Modifier.PUBLIC, "calendar", String.class, Principal.class, Model.class),
method(
Modifier.PUBLIC,
"create",
String.class,
Principal.class,
LocalDate.class,
String.class,
boolean.class,
RedirectAttributes.class),
method(
Modifier.PUBLIC,
"update",
String.class,
Principal.class,
long.class,
long.class,
LocalDate.class,
String.class,
boolean.class,
RedirectAttributes.class));
assertMethodSurface(
AttendanceApplicationService.class,
method(Modifier.PUBLIC, "checkIn", AttendanceRecord.class, long.class),
method(Modifier.PUBLIC, "checkOut", AttendanceRecord.class, long.class),
method(Modifier.PUBLIC, "currentState", AttendanceCurrentState.class, long.class),
method(
Modifier.PUBLIC,
"history",
List.class,
AttendanceActor.class,
long.class,
LocalDate.class,
LocalDate.class),
method(Modifier.PUBLIC, "currentBusinessDate", LocalDate.class));
assertMethodSurface(
AttendanceCurrentUserService.class,
method(Modifier.PUBLIC, "actor", AttendanceActor.class, Principal.class));
assertMethodSurface(
AttendanceService.class,
method(
Modifier.PUBLIC,
"checkIn",
AttendanceRecord.class,
long.class,
Instant.class,
AttendancePolicy.class,
AttendanceDayContext.class,
Optional.class),
method(
Modifier.PUBLIC,
"checkOut",
AttendanceRecord.class,
Optional.class,
Instant.class));
assertMethodSurface(
CalendarApplicationService.class,
method(
Modifier.PUBLIC,
"createManual",
GlobalCalendarEvent.class,
AttendanceActor.class,
LocalDate.class,
String.class,
boolean.class),
method(
Modifier.PUBLIC,
"updateManual",
GlobalCalendarEvent.class,
AttendanceActor.class,
long.class,
long.class,
LocalDate.class,
String.class,
boolean.class),
method(Modifier.PUBLIC, "list", List.class, LocalDate.class, LocalDate.class),
method(Modifier.PUBLIC, "isGlobalDayOff", boolean.class, LocalDate.class));
}
private static void assertConstructors(
Class<?> type, ConstructorContract... expectedConstructors) {
List<ConstructorContract> actual = Arrays.stream(type.getDeclaredConstructors())
.map(ConstructorContract::from)
.toList();
assertThat(actual).as(type.getName()).containsExactlyInAnyOrder(expectedConstructors);
}
private static void assertRecordComponents(
Class<?> type, RecordComponentContract... expectedComponents) {
assertThat(type.isRecord()).as(type.getName()).isTrue();
assertThat(Arrays.stream(type.getRecordComponents())
.map(component -> new RecordComponentContract(component.getName(), component.getType()))
.toList())
.as(type.getName())
.containsExactly(expectedComponents);
}
private static void assertMethodSurface(Class<?> type, MethodContract... expectedMethods) {
List<MethodContract> actual = Arrays.stream(type.getDeclaredMethods())
.filter(method -> Modifier.isPublic(method.getModifiers())
|| Modifier.isProtected(method.getModifiers()))
.map(MethodContract::from)
.toList();
assertThat(actual).as(type.getName()).containsExactlyInAnyOrder(expectedMethods);
}
private static ConstructorContract constructor(int modifiers, Class<?>... parameterTypes) {
return new ConstructorContract(modifiers, List.of(parameterTypes));
}
private static RecordComponentContract component(String name, Class<?> type) {
return new RecordComponentContract(name, type);
}
private static MethodContract method(
int modifiers, String name, Class<?> returnType, Class<?>... parameterTypes) {
return new MethodContract(modifiers, name, returnType, List.of(parameterTypes));
}
private record ConstructorContract(int modifiers, List<Class<?>> parameterTypes) {
private static ConstructorContract from(Constructor<?> constructor) {
return new ConstructorContract(
constructor.getModifiers(), List.of(constructor.getParameterTypes()));
}
}
private record RecordComponentContract(String name, Class<?> type) {}
private record MethodContract(
int modifiers, String name, Class<?> returnType, List<Class<?>> parameterTypes) {
private static MethodContract from(Method method) {
return new MethodContract(
method.getModifiers(),
method.getName(),
method.getReturnType(),
List.of(method.getParameterTypes()));
}
}
}