From fe62c06e45d88eff2a0ee831e2b9e56c36f54846 Mon Sep 17 00:00:00 2001 From: Patrick Schiller Date: Wed, 9 Sep 2026 18:46:05 +0200 Subject: [PATCH] feat: release OpenClockwork 2.0.0 with Solo and Team modes Add the complete Solo workflow, preserved Team upgrade path, customer time reporting, guarded maintenance commands, comprehensive operator documentation and version-aligned release automation. Signed-off-by: Patrick Schiller --- .dockerignore | 2 + .env.dev.example | 5 +- .env.example | 5 + .env.ipad.example | 7 +- .env.prod.example | 2 +- .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 5 +- .gitignore | 2 + Dockerfile.api | 4 + FEATURES.md | 133 +- README.md | 184 +- RELEASE_NOTES.md | 261 + RELEASING.md | 18 +- ROADMAP.md | 179 +- UPGRADING.md | 310 +- apps/api-e2e/package.json | 7 + apps/api-e2e/src/api/create-admin.e2e.spec.ts | 35 + .../api/project-session-boundary.e2e.spec.ts | 180 + apps/api-e2e/src/api/projects.e2e.spec.ts | 40 +- .../api/realtime-session-boundary.e2e.spec.ts | 183 + .../api/solo-customers-reports.e2e.spec.ts | 442 ++ .../src/api/solo-installation.e2e.spec.ts | 600 ++ .../src/api/solo-owner-recovery.e2e.spec.ts | 251 + apps/api-e2e/src/api/solo-summary.e2e.spec.ts | 518 ++ .../src/api/solo-time-hints.e2e.spec.ts | 293 + apps/api-e2e/src/api/solo-time.e2e.spec.ts | 639 ++ apps/api-e2e/src/support/database-target.ts | 16 + apps/api-e2e/src/support/global-setup.ts | 21 +- apps/api-e2e/src/support/test-app.ts | 20 + apps/api-e2e/src/support/test-setup.ts | 11 +- apps/api/openapi.json | 5923 ++++++++++++----- apps/api/src/app/app.module.ts | 4 + apps/api/src/app/auth/auth.controller.ts | 35 +- apps/api/src/app/auth/auth.dto.ts | 31 +- apps/api/src/app/auth/auth.service.ts | 100 +- apps/api/src/app/auth/jwt.strategy.ts | 26 +- .../src/app/customers/customers.controller.ts | 78 + apps/api/src/app/customers/customers.dto.ts | 52 + .../api/src/app/customers/customers.module.ts | 11 + .../src/app/customers/customers.service.ts | 180 + .../src/app/employees/employees.service.ts | 91 +- apps/api/src/app/events/events.gateway.ts | 171 +- apps/api/src/app/health/health.controller.ts | 2 +- .../installation/installation.controller.ts | 132 + .../src/app/installation/installation.dto.ts | 138 + .../app/installation/installation.module.ts | 20 + .../app/installation/installation.response.ts | 133 + .../app/installation/installation.service.ts | 658 ++ .../installation/personal-hints.service.ts | 231 + .../installation/personal-summary.service.ts | 385 ++ .../src/app/installation/personal-windows.ts | 61 + .../src/app/installation/solo-access.guard.ts | 48 + .../src/app/projects/projects.controller.ts | 60 +- apps/api/src/app/projects/projects.dto.ts | 144 +- apps/api/src/app/projects/projects.module.ts | 3 +- apps/api/src/app/projects/projects.service.ts | 464 +- .../app/projects/solo-project-access.guard.ts | 33 + .../api/src/app/reports/reports.controller.ts | 36 +- apps/api/src/app/reports/reports.dto.ts | 89 + apps/api/src/app/reports/reports.service.ts | 225 +- .../src/app/time-entries/capture-summary.ts | 81 + .../time-entries/time-entries.controller.ts | 90 +- .../src/app/time-entries/time-entries.dto.ts | 228 +- .../app/time-entries/time-entries.service.ts | 1276 +++- apps/api/src/generate-openapi.ts | 2 +- apps/api/src/main.ts | 2 +- apps/web/src/api/client.ts | 27 +- apps/web/src/api/generated.ts | 1755 ++++- apps/web/src/api/solo.ts | 269 + apps/web/src/app/AppShell.spec.tsx | 42 + apps/web/src/app/AppShell.tsx | 38 +- apps/web/src/app/app.tsx | 80 +- apps/web/src/app/i18n.tsx | 3 + apps/web/src/app/installation.ts | 15 + apps/web/src/app/navigation.spec.ts | 22 + apps/web/src/app/navigation.ts | 62 +- apps/web/src/app/realtime.ts | 7 + apps/web/src/app/solo-errors.spec.ts | 69 + apps/web/src/app/solo-errors.ts | 82 + apps/web/src/app/solo-i18n.ts | 534 ++ apps/web/src/app/solo-routing.spec.tsx | 58 + apps/web/src/app/solo-time.spec.ts | 35 + apps/web/src/app/solo-time.ts | 38 + apps/web/src/components/ui/dialog.tsx | 82 +- apps/web/src/routes/solo/DailyBlockDialog.tsx | 131 + apps/web/src/routes/solo/PersonalHints.tsx | 51 + apps/web/src/routes/solo/SoloCalendarPage.tsx | 274 + .../routes/solo/SoloConfirmDialog.spec.tsx | 104 + .../web/src/routes/solo/SoloCustomersPage.tsx | 219 + .../web/src/routes/solo/SoloDashboardPage.tsx | 190 + .../web/src/routes/solo/SoloMonthCalendar.tsx | 185 + apps/web/src/routes/solo/SoloPages.spec.tsx | 750 +++ apps/web/src/routes/solo/SoloProjectsPage.tsx | 498 ++ apps/web/src/routes/solo/SoloReportsPage.tsx | 382 ++ apps/web/src/routes/solo/SoloSettingsPage.tsx | 802 +++ apps/web/src/routes/solo/SoloTimesPage.tsx | 786 +++ apps/web/src/routes/solo/SoloUi.tsx | 307 + apps/web/src/styles.css | 47 +- docker-compose.dev.yml | 8 +- docker-compose.solo-test.yml | 66 + docs/IPAD_TERMINAL_SETUP.de.md | 91 +- docs/OPERATING_MODES.md | 128 + docs/SOLO_MODE.md | 281 + docs/TEAM_MODE.md | 216 + infra/azure/README.md | 11 + infra/azure/main.bicep | 12 +- infra/azure/main.example.bicepparam | 4 + ops/db-target-safety.cjs | 168 + ops/db-target-safety.test.mjs | 284 + ops/release-notes.mjs | 36 + ops/release-notes.test.mjs | 32 + ops/safe-db-reset.mjs | 36 + ops/upgrade-smoke.mjs | 31 + ops/verify-release-version.mjs | 42 +- package.json | 5 +- prisma/create-admin-lib.ts | 39 +- prisma/create-admin.ts | 53 +- prisma/demo-reset.ts | 77 +- .../20260908120000_solo_mode/migration.sql | 169 + .../migration.sql | 2 + .../migration.sql | 7 + .../migration.sql | 12 + .../migration.sql | 9 + prisma/reset-owner-password-lib.ts | 111 + prisma/reset-owner-password.ts | 49 + prisma/schema.prisma | 123 + prisma/seed.ts | 20 +- 127 files changed, 22998 insertions(+), 2689 deletions(-) create mode 100644 apps/api-e2e/src/api/project-session-boundary.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/realtime-session-boundary.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/solo-customers-reports.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/solo-installation.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/solo-owner-recovery.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/solo-summary.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/solo-time-hints.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/solo-time.e2e.spec.ts create mode 100644 apps/api-e2e/src/support/database-target.ts create mode 100644 apps/api/src/app/customers/customers.controller.ts create mode 100644 apps/api/src/app/customers/customers.dto.ts create mode 100644 apps/api/src/app/customers/customers.module.ts create mode 100644 apps/api/src/app/customers/customers.service.ts create mode 100644 apps/api/src/app/installation/installation.controller.ts create mode 100644 apps/api/src/app/installation/installation.dto.ts create mode 100644 apps/api/src/app/installation/installation.module.ts create mode 100644 apps/api/src/app/installation/installation.response.ts create mode 100644 apps/api/src/app/installation/installation.service.ts create mode 100644 apps/api/src/app/installation/personal-hints.service.ts create mode 100644 apps/api/src/app/installation/personal-summary.service.ts create mode 100644 apps/api/src/app/installation/personal-windows.ts create mode 100644 apps/api/src/app/installation/solo-access.guard.ts create mode 100644 apps/api/src/app/projects/solo-project-access.guard.ts create mode 100644 apps/api/src/app/time-entries/capture-summary.ts create mode 100644 apps/web/src/api/solo.ts create mode 100644 apps/web/src/app/installation.ts create mode 100644 apps/web/src/app/solo-errors.spec.ts create mode 100644 apps/web/src/app/solo-errors.ts create mode 100644 apps/web/src/app/solo-i18n.ts create mode 100644 apps/web/src/app/solo-routing.spec.tsx create mode 100644 apps/web/src/app/solo-time.spec.ts create mode 100644 apps/web/src/app/solo-time.ts create mode 100644 apps/web/src/routes/solo/DailyBlockDialog.tsx create mode 100644 apps/web/src/routes/solo/PersonalHints.tsx create mode 100644 apps/web/src/routes/solo/SoloCalendarPage.tsx create mode 100644 apps/web/src/routes/solo/SoloConfirmDialog.spec.tsx create mode 100644 apps/web/src/routes/solo/SoloCustomersPage.tsx create mode 100644 apps/web/src/routes/solo/SoloDashboardPage.tsx create mode 100644 apps/web/src/routes/solo/SoloMonthCalendar.tsx create mode 100644 apps/web/src/routes/solo/SoloPages.spec.tsx create mode 100644 apps/web/src/routes/solo/SoloProjectsPage.tsx create mode 100644 apps/web/src/routes/solo/SoloReportsPage.tsx create mode 100644 apps/web/src/routes/solo/SoloSettingsPage.tsx create mode 100644 apps/web/src/routes/solo/SoloTimesPage.tsx create mode 100644 apps/web/src/routes/solo/SoloUi.tsx create mode 100644 docker-compose.solo-test.yml create mode 100644 docs/OPERATING_MODES.md create mode 100644 docs/SOLO_MODE.md create mode 100644 docs/TEAM_MODE.md create mode 100644 ops/db-target-safety.cjs create mode 100644 ops/db-target-safety.test.mjs create mode 100644 ops/release-notes.mjs create mode 100644 ops/release-notes.test.mjs create mode 100644 ops/safe-db-reset.mjs create mode 100644 prisma/migrations/20260908120000_solo_mode/migration.sql create mode 100644 prisma/migrations/20260908130000_solo_session_version/migration.sql create mode 100644 prisma/migrations/20260908140000_solo_leave_versions/migration.sql create mode 100644 prisma/migrations/20260908150000_solo_personal_windows/migration.sql create mode 100644 prisma/migrations/20260908160000_solo_required_target/migration.sql create mode 100644 prisma/reset-owner-password-lib.ts create mode 100644 prisma/reset-owner-password.ts diff --git a/.dockerignore b/.dockerignore index 4f53a6e..7954374 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,5 @@ coverage .DS_Store legacy docs +backups +**/*.dump diff --git a/.env.dev.example b/.env.dev.example index 225c07d..4af1240 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -52,8 +52,9 @@ API_CORS_ORIGINS=http://localhost,http://localhost:8080 # Host port exposed by the frontend container. WEB_PORT=8080 -# Pre-fill the synthetic seed login and display the demo-data notice. -DEMO_MODE=true +# Startup never seeds. Bootstrap the first account with db:create-admin. +# Enable the demo UI only after explicitly seeding a disposable *_dev/_demo DB. +DEMO_MODE=false # Optional voluntary-support destination. Set to an empty value to hide the # support button; terminal functionality is never affected. diff --git a/.env.example b/.env.example index 82ba07a..a84aebf 100644 --- a/.env.example +++ b/.env.example @@ -61,5 +61,10 @@ ATTACHMENTS_DIR=data/attachments # Destructive public-demo reset. The command refuses to run unless both # values match exactly. Never configure these in staging or production. +# Seed/reset also refuse NODE_ENV=production and require an explicitly named +# disposable openclockwork_dev, openclockwork_test, or openclockwork_demo database. +# Optional alphanumeric suffixes separated by underscores are supported. +# OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_demo +# OPENCLOCKWORK_RESET_CONFIRM_DATABASE=openclockwork_demo # DEMO_RESET_ENABLED=true # DEMO_RESET_CONFIRMATION=DELETE-AND-RESEED-OPENClockwork-DEMO diff --git a/.env.ipad.example b/.env.ipad.example index 7c579ac..cd5f384 100644 --- a/.env.ipad.example +++ b/.env.ipad.example @@ -25,9 +25,10 @@ DB_PORT=5432 API_PORT=3001 WEB_PORT=8080 -# The iPad stack uses only synthetic seed data and therefore enables the -# pre-filled demo login. -DEMO_MODE=true +# Startup never seeds. Bootstrap a Team administrator and a separate employee. +# Enable only for an already explicitly seeded, disposable demo database; +# this flag merely pre-fills the demo login and never creates accounts. +DEMO_MODE=false # Optional voluntary-support destination. Empty hides the button without # changing terminal availability. diff --git a/.env.prod.example b/.env.prod.example index df0e08d..ded8a3e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -3,7 +3,7 @@ # Pin deployments to an immutable OpenClockwork release. Do not use `latest` # for production upgrades. -OPENCLOCKWORK_VERSION=1.4.0 +OPENCLOCKWORK_VERSION=2.0.0 # Keep these names unchanged across upgrades. OPENCLOCKWORK_DB_VOLUME=openclockwork-db-data-prod OPENCLOCKWORK_ATTACHMENTS_VOLUME=openclockwork-attachments-prod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfaf35b..5557e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,9 @@ jobs: - name: Generate Prisma client run: pnpm prisma generate + - name: Test release tooling and database target safety + run: pnpm nx run api-e2e:test-ops + # Caches the local Nx task cache between runs so unchanged projects # don't re-execute. The run_id in the primary key forces a fresh write # every build; the restore-keys fall back to the most recent good @@ -120,6 +123,8 @@ jobs: env: DATABASE_URL: postgresql://openclockwork:openclockwork@localhost:5433/openclockwork_test?schema=public + E2E_DATABASE_URL: postgresql://openclockwork:openclockwork@localhost:5433/openclockwork_test?schema=public + E2E_ADMIN_DATABASE_URL: postgresql://openclockwork:openclockwork@localhost:5433/postgres JWT_SECRET: ci-jwt-secret-do-not-use-in-prod ERP_API_KEY: ci-erp-key # Pin the legacy fixtures to an explicit zone, independent of deployment defaults. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05200ed..8f55c20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,9 @@ jobs: - name: Generate Prisma client run: pnpm prisma generate + - name: Test release tooling and database target safety + run: pnpm nx run api-e2e:test-ops + - name: Lint workspace run: pnpm nx run-many -t lint @@ -161,7 +164,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ github.ref_name }} run: | - release_notes=$( **Project status:** Stable. Published versions follow semantic versioning and -> include release notes, forward-only database migrations, and documented -> upgrade steps. The capabilities below are implemented and covered by -> automated tests. - -For planned Solo mode, invoice creation, and CAUR-based agent billing, see the -[project roadmap](ROADMAP.md). +OpenClockwork 2.0.0 provides **Solo** personal work tracking and **Team** time and +attendance in one responsive, self-hosted application. Both keep deployment, +data, and configuration under the operator's control, with German and English UI. + +This page describes implemented capabilities, not a guarantee that every rule, +device, or external browser handoff has been certified. See +[release notes](RELEASE_NOTES.md) for release-specific validation and +[UPGRADING.md](UPGRADING.md) for operational checks. Version 2.0 marks the +deliberate two-mode product generation; it does not imply that existing Team +installations need a reset or an automatic conversion. + +Start with [Operating modes](docs/OPERATING_MODES.md), the +[Solo guide](docs/SOLO_MODE.md), or the [Team guide](docs/TEAM_MODE.md). +Invoices, rates, payments, and CAUR coding-agent usage import/accounting are +**not included**; they remain on the [roadmap](ROADMAP.md).

Paired OpenClockwork tablet kiosk with a rotating QR code @@ -27,20 +30,72 @@ For planned Solo mode, invoice creation, and CAUR-based agent billing, see the | Audience | Main capabilities | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Solo owner | Personal timer, direct corrections, calendar, customers, billable projects/orders, timesheets, optional effective-dated accounts | | Employees | Clock in/out, scan tablet QR codes, book daily targets, manage requests and absences, view calendars and time accounts | | Managers | Review team requests, handle substitute workflows, approve or return corrections, use bulk actions, inspect project data | | HR administrators | Manage employees, schedules, leave, projects, reports, terminal kiosks, geofences, devices, and production bootstrap | | Kiosk devices | Pair once, display a branded rotating challenge, refresh automatically, report health, and operate with least-privilege credentials | | Integrations | Consume documented REST endpoints, generated types, realtime events, ERP exports, and health checks | +Employee, manager, HR, kiosk, and ERP capabilities in the table refer to Team. +Solo exposes only the owner's authorised personal endpoints; old Team routes +are blocked by the server, not just removed from the menu. + +## Solo owner workflow + +| Capability | What it provides | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Owner bootstrap and setup | One explicit owner, generated initial password, working-timezone confirmation, no mandatory HR policy | +| Personal navigation | Overview, Times, Calendar, Customers, Projects, Reports, and Settings | +| Time capture | One running timer, manual completed intervals, atomic running-project switch, optional activity and separate private notes | +| Corrections and splits | Direct owner edits, reasoned correction/cancellation, capture-group break preservation, revision conflicts, and audit history | +| Calendar | Completed net time plus free days, vacation, sickness, and training; first/last half-day flags and cancellation history | +| Customers | Name, optional code/internal note, archive/reactivation, and deletion protection for referenced records | +| Projects and orders | Customer or internal projects, automatic owner assignment, service orders, planned-hour budgets, and net actual totals | +| Billability | Project default, inheritable order override, and per-booking billable/non-billable value; no monetary calculation | +| Reports | Date/customer/project/order/billable/unassigned filters; gross, break, net, and billable-net totals; CSV and browser print/PDF | +| Optional policies | Weekly target, leave account, holiday dates, break rules, personal frame/core hints, daily target blocks, and GPS | +| Policy history | Effective dates, scheduled versions, annual leave carry-over/expiry/adjustments, and recorded mode-accounting boundaries | +| Identity and recovery | Self-service profile/password changes and a local operator CLI limited to the existing active Solo owner | + +Fresh Solo installations begin without target or leave accounts, automatic +breaks, regional holiday presets, time-window hints, daily blocks, or GPS. +Personal hints do not become Team approval obligations. Closed intervals cannot +overlap or end in the future; ambiguous manual/correction times require an +explicit offset during daylight-saving changes. + +Reports exclude open timers and cancelled work from final totals and never +include private notes. Archive operations cannot strand a running timer on an +unbookable customer/project/order. A booked project cannot be reassigned to a +different customer; create a new project instead. + +## Safe operating-mode changes + +An upgrade retains Team mode. A subsequent mode preview checks blockers without +changing configuration; confirmation repeats the checks transactionally. +Entering Solo requires one remaining active administrator, no running timers, +no unresolved requests/time approvals, and no active terminals. Entering Team +requires completed timers before adding further people. + +Identities, customers, projects, bookings, and history are retained. Solo work +keeps its original approval mode. Access changes immediately; when today already +contains effective work or time off, the accounting mode starts on the next +working-timezone calendar date. See [the mode guide](docs/OPERATING_MODES.md). + +Solo customer management and personal billable reports are not Team screens in +2.0. Preserving records across a switch does not make every mode-specific +workflow available in both modes. + ## Mobile PWA -The React application is installable as a Progressive Web App and adapts down -to narrow phone screens without horizontal scrolling. The compact profile icon +The React application is installable as a Progressive Web App with layouts for +desktop, tablet, and narrow phone screens. The compact profile icon opens an identity menu with name, email, role, and logout. German and English can be switched from the login, employee shell, and kiosk. -The primary phone navigation is intentionally task-focused: +The primary phone navigation is intentionally task-focused. Solo uses +**Overview, Times, Calendar, Reports, More**; customers, projects, and settings +remain available from More. Team uses: 1. Dashboard 2. Booking @@ -56,7 +111,15 @@ administration remain reachable through the role-aware **More** menu. Mobile vacation request with live leave balance

-## QR tablet terminals +The PWA is online-first: mutations require server confirmation and are not +queued as offline bookings. A running server timer survives a disconnected +client. Downloads and print/PDF depend on the browser/OS handoff: verify the +actual saved file or print result in your deployment's browser, especially when +using an embedded browser surface. + +The screenshots on this page show the Team and kiosk workflows. + +## QR tablet terminals (Team) OpenClockwork turns a standard tablet into a shared, branded time-clock display without requiring RFID badges, proprietary readers, or biometrics. The mounted @@ -82,7 +145,7 @@ CA/server certificates, loopback-only raw service ports, an Nginx TLS gateway, Guided Access guidance, a site acceptance checklist, and MDM recommendations. See [the German iPad setup guide](docs/IPAD_TERMINAL_SETUP.de.md). -## Employee experience +## Employee experience (Team) | Capability | What it provides | | --------------------------------- | --------------------------------------------------------------------------------------------------------------- | @@ -106,7 +169,7 @@ See [the German iPad setup guide](docs/IPAD_TERMINAL_SETUP.de.md). | Absence records | Record sickness, training, and flextime days without mobile layout overflow | | Theme preference | Light, dark, or operating-system theme | -## Approval workflows +## Approval workflows (Team) OpenClockwork models approvals as explicit state transitions instead of one approved/rejected flag. @@ -131,7 +194,7 @@ Employee submits - Daily-target blocks bypass the workflow only when HR enables the employee and all schedule, holiday, absence, conflict, frame, and break checks pass -## HR and administration +## HR and administration (Team) | Capability | What it provides | | ----------------------- | ------------------------------------------------------------------------------------------------------ | @@ -148,11 +211,12 @@ Employee submits | Working-time reports | HR-only start, end, break, gross, net, approval, and CSV reporting, with optional clocking locations | Production starts with an empty database. The interactive -`prisma/create-admin.ts` bootstrap creates exactly one first HR administrator, +`prisma/create-admin.ts` bootstrap offers Solo or Team and creates exactly one +first owner/HR administrator, prints a random initial password once, and refuses to run after any employee has been created. -## Project management and reporting +## Project management and reporting (Team) Projects combine employee assignments, service orders, planned hours, actual bookings, and customer-facing activity reports in one administrative workflow. @@ -178,7 +242,7 @@ clocking locations are excluded by default and require an explicit HR action; operators must document a lawful purpose and suitable retention period before using them. -## Configurable working-time rules +## Configurable working-time rules (Team) Working-time rules are visible in code and covered by focused tests. Operators remain responsible for validating their organisation's exact policies. @@ -208,14 +272,17 @@ local rules and calendar coverage for each period in use. ## Languages, accessibility, and responsive design -- German and English UI across login, employee, manager, HR, and kiosk routes +- German and English UI across login, Solo, employee, manager, HR, and kiosk routes - Central translation catalogue for labels, validation, states, and empty views - Browser-language detection with English fallback and regional date formatting - Persistent language and theme preferences - Keyboard-operable account and mobile overflow menus - Semantic labels for navigation, forms, buttons, progress indicators, and QR images -- Tested mobile breakpoints at 320 px and 375 px without horizontal overflow +- Narrow-screen layouts, including compact Solo navigation below 360 px, with + regression coverage for 320/375 px layout constraints +- Accessible in-app confirmations for Solo calendar cancellation and unused + record deletion, with Cancel initially focused and pending actions disabled ## Security and data handling @@ -225,6 +292,8 @@ local rules and calendar coverage for each period in use. - Dedicated API-key protection for machine-to-machine ERP exports - Authenticated Socket.IO connections - Password hashing and refresh-token rotation +- Session-version invalidation after password changes or owner recovery +- Owner-only Solo access and stale-revision checks on personal mutations - Separate `TERMINAL_QR_SECRET` for kiosk pairing and QR signing material - Hash-only device credential storage and immediate revocation - Short-lived challenges, daily key rotation, rate limiting, expiry, and @@ -253,6 +322,12 @@ The generated and committed OpenAPI specification lives at - Health endpoint for deployment checks - Generated TypeScript client types for the web application - Language-neutral API values translated by the web catalogue +- Solo installation/settings, personal days/history, owner time mutations, + customers, and personal reports, protected by the active mode and owner identity + +Machine-to-machine Team exports and terminal capabilities are not enabled by a +Solo API token. CAUR ingestion is not a hidden or experimental public endpoint +in this release. ## Self-hosting and operations @@ -260,8 +335,9 @@ The generated and committed OpenAPI specification lives at - Development and production Docker Compose configurations - Trusted-HTTPS iPad overlay with runtime-configuration validation - PostgreSQL with versioned Prisma migrations -- Synthetic seed data for local evaluation only -- Empty production bootstrap with a one-time HR admin command +- Explicitly guarded synthetic seed data for disposable local evaluation only; + normal development startup does not seed automatically +- Empty production bootstrap with a one-time Solo owner/Team administrator command - Azure Container Apps, ACR, PostgreSQL Flexible Server, Key Vault, Blob Storage, and Log Analytics reference infrastructure - Dedicated terminal QR secret in Compose and Azure Key Vault @@ -280,7 +356,10 @@ installation identity is sent back to OpenClockwork. ## Explore the project - [Main README and installation guide](README.md) -- [Roadmap: Solo mode, invoices, and CAUR-based agent billing](ROADMAP.md) +- [Choose an operating mode](docs/OPERATING_MODES.md) +- [Use Solo mode](docs/SOLO_MODE.md) +- [Use Team mode](docs/TEAM_MODE.md) +- [Roadmap: billing, agent usage, and internationalisation](ROADMAP.md) - [Set up and test an iPad terminal](docs/IPAD_TERMINAL_SETUP.de.md) - [Upgrade an existing installation](UPGRADING.md) - [Review published releases](https://github.com/patrickschiller/openclockwork/releases) diff --git a/README.md b/README.md index b8a1248..703fd73 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- Open-source time and attendance with configurable work rules, modern self-hosting, and no proprietary punch-clock hardware. + Self-hosted time tracking for one-person businesses and teams. Your work, your rules, your data.

@@ -14,21 +14,37 @@ Latest GitHub release

-OpenClockwork is a mobile-first, self-hostable working-time system for small and -mid-sized organisations in any country. Employees can clock in and out, scan a -rotating QR code from a wall-mounted tablet, or—when HR explicitly enables -it—book their contractual daily target as one completed block. - -The domain model covers real working-time behaviour: configurable break deduction, -target/actual accounts, schedules and core hours, selectable holiday calendars -and custom holiday dates, leave balances, multi-stage approvals, projects, -service orders, and auditable reporting. The UI ships in German and English and -works as an installable PWA on phones, tablets, and desktops. - -> **Where OpenClockwork is heading:** Read the [project roadmap](ROADMAP.md) for -> the complete **Solo mode**, **invoice creation**, and **CAUR-based agent usage -> accounting and billing** plans. These are planned capabilities; the current -> application provides team time tracking and attendance. +OpenClockwork 2.0 brings two operating modes to one open-source application. +**Solo** helps an independent professional track personal work, organise customer +projects, and produce timesheets without inventing an HR department. **Team** +supports employee attendance, work schedules, leave, approvals, and tablet +terminals. Both use the same self-hosted PostgreSQL, API, and responsive PWA. + +The interface is available in German and English on phones, tablets, and +desktops. Language is independent of working-time rules: targets, breaks, +holidays, and leave are explicit configuration, not assumptions about your +country. No proprietary time-clock hardware or hosted subscription is required. + +**Existing installations remain in Team mode after an upgrade.** A mode change +is a separate, authenticated administrator action with a preview and safety +checks. Invoice creation, monetary billing, and CAUR coding-agent usage import +are not included in 2.0.0; see the [roadmap](ROADMAP.md). + +## Choose your mode + +| | Solo | Team | +| --------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| Best fit | One person tracking their own work | An organisation managing multiple people | +| Daily workflow | Timer, manual entries, direct corrections, personal calendar | Employee bookings, requests, manager/HR workflows | +| Work rules | Optional targets, leave account, breaks, and personal hints | Employee contracts, schedules, leave allowances, and approvals | +| Customer work | Customers, projects/orders, billable classification, personal timesheets | Assigned projects/orders, PLAN/IST reports, HR working-time reports | +| Shared terminal | Not available in Solo | Paired tablet QR terminals with optional geofencing | +| Start here | [Solo guide](docs/SOLO_MODE.md) | [Team guide](docs/TEAM_MODE.md) | + +Read [Operating modes](docs/OPERATING_MODES.md) for boundaries and safe +transitions. Solo is not a separate account tier or a license restriction; it +is an installation-wide workflow and access mode. Team does not yet expose the +Solo customer-management and billable-timesheet UI.

OpenClockwork tablet terminal with a rotating QR code @@ -41,12 +57,16 @@ works as an installable PWA on phones, tablets, and desktops.

- One responsive PWA for employees, managers, HR, and paired kiosk devices.
+ Team-mode examples: employee booking, QR scanning, and leave requests.
Explore the complete feature overview

## Highlights +- **A complete personal workflow.** In Solo, start with all attendance-related + accounts disabled, capture actual work, correct or cancel it with an audit + trail, organise customers/projects/orders, and export billable or non-billable + timesheets. Private notes stay out of customer reports. - **Flexible time capture.** Clock actual start/end times, add an optional GPS position and project, or use an HR-enabled daily-target block. - **QR tablet terminal.** Pair an iPad or another tablet once, show a rotating @@ -58,7 +78,7 @@ works as an installable PWA on phones, tablets, and desktops. - **Configurable working-time rules.** Break deduction, working frames, core hours, target/actual balances, public holidays, and leave calculations live in tested backend/shared-domain code. -- **Real approval workflows.** Vacation, home office, special leave, time +- **Team approval workflows.** Vacation, home office, special leave, time corrections, substitute confirmation, manager approval, HR confirmation, bulk actions, and workflow history. - **Projects and reporting.** Assign employees, structure projects by service @@ -67,8 +87,13 @@ works as an installable PWA on phones, tablets, and desktops. - **Self-hosted and API-first.** PostgreSQL, NestJS, React, OpenAPI, Socket.IO, Docker, and an Azure reference deployment—without SaaS lock-in. - **Localised interface.** German and English translations, browser-aware dates, - and a persistent language switcher across the login, employee, manager, HR, and - kiosk experiences. + and a persistent language switcher across login, Solo, employee, manager, HR, + and kiosk experiences. + +Solo corrections are owner actions, not employee approval requests. Optional +personal policies are effective-dated; changing tomorrow's rules does not +rewrite yesterday's bookings. See the [feature overview](FEATURES.md) for a +mode-by-mode breakdown. ## Use in any country @@ -86,7 +111,7 @@ real data. A kiosk's display timezone does not change payroll or working-day boundaries. Multiple simultaneous employee working timezones and additional maintained regional calendars are covered in the [roadmap](ROADMAP.md). -## Tablet terminal +## Tablet terminal (Team mode) An HR administrator creates a terminal with an internal name, visible location, custom message, optional logo, IANA time zone, and optional GPS geofence. After @@ -118,10 +143,18 @@ device replacement, revocation, and troubleshooting. ## Project status -**Stable and ready for self-hosting.** Employee, manager, HR, terminal, -approval, reporting, and deployment workflows are covered by automated tests. -Stable releases follow semantic versioning and include release notes and upgrade -instructions. +OpenClockwork is intended for self-hosting, with automated domain, API, and UI +tests, versioned releases, and documented upgrade procedures. See +[release notes](RELEASE_NOTES.md) for the scope and validation of each version; +do not treat automated coverage as certification of every browser, device, or +organisation-specific policy. + +**Why 2.0?** This version deliberately marks a new product generation: Solo and +Team are both first-class workflows. The major number is not a claim that the +release requires a data reset or intentionally breaks existing Team APIs. +Existing installations migrate forward and retain Team mode. Future +compatibility changes follow semantic versioning and will be called out in +release notes and [UPGRADING.md](UPGRADING.md). Operators must still validate organisation-specific working-time rules, collective agreements, payroll integrations, privacy requirements, backups, @@ -136,6 +169,12 @@ appropriate access, and retention periods for this personal data. See the [latest release](https://github.com/patrickschiller/openclockwork/releases/latest) and read [UPGRADING.md](UPGRADING.md) before changing an existing installation. +The PWA is online-first. A running timer remains server state while a device is +offline; new writes require server confirmation and are not queued as offline +bookings. CSV export starts a browser download, and print/PDF uses the browser's +print dialog. Verify both handoffs in your chosen browser or installed PWA; +embedded browsers may handle downloads and printing differently. + ## Tech stack | Layer | Technology | @@ -168,7 +207,9 @@ assets/ Brand sources and documentation screenshots ## Getting started -Prerequisites: **Node.js 20+**, **pnpm 9+**, and **Docker**. +For production, use the [versioned Docker installation](#production-installation-step-by-step) +below; Node.js and pnpm are not required on that host. Source development needs +**Node.js 20+**, **pnpm 9+**, and **Docker**. ### Development with Node.js and Docker @@ -177,8 +218,11 @@ git clone https://github.com/patrickschiller/openclockwork.git cd openclockwork pnpm install +cp .env.example .env docker compose up -d db -pnpm prisma migrate dev +pnpm prisma generate +pnpm prisma migrate deploy +pnpm db:create-admin pnpm nx run-many -t serve -p api,web ``` @@ -187,6 +231,10 @@ Open `http://localhost:4200`. Vite proxies API calls to back to English. Use the language menu on the login screen or in the application header to choose English or German; your choice is saved. +The bootstrap command is for an empty employee table and asks you to choose +Solo or Team. Keep an existing `.env` and its database identity when returning +to an existing checkout; do not rerun initial setup or copy over its secrets. + ### Full local Docker stack ```bash @@ -204,8 +252,19 @@ docker compose \ | API | `http://localhost:3001` | `3001:3001` | | PostgreSQL | `localhost:5432` | configurable with `DB_PORT` | -The API applies pending migrations and loads synthetic development data before -starting. Stop the stack with: +The API applies pending migrations before starting. It does **not** automatically +load synthetic data: ordinary development startup must not overwrite a database +or silently create demo users. For a new empty database, use the same interactive +bootstrap entry point, targeting the development stack: + +```bash +docker compose -f docker-compose.dev.yml --env-file .env.dev \ + exec api node --import tsx prisma/create-admin.ts +``` + +Keep demos in a deliberately separate development/demo database and follow the +seed command's explicit safety requirements. Never seed a production database +or use seeding to repair an existing installation. Stop the stack with: ```bash docker compose -f docker-compose.dev.yml --env-file .env.dev down @@ -239,7 +298,9 @@ the regular HTTP port to a tablet. Production installations use versioned API and web images from the GitHub Container Registry. The database starts empty: production never loads the development/demo seed. Follow every step below to configure the installation -and create its first HR administrator. +and create its first Solo owner or Team HR administrator. Upgrading an existing +database is a different workflow: use [UPGRADING.md](UPGRADING.md), do not run +bootstrap again, and keep the existing volume names. ### 1. Prepare the host and configuration @@ -249,6 +310,7 @@ repository and enter its directory: ```bash git clone https://github.com/patrickschiller/openclockwork.git cd openclockwork +git switch --detach v2.0.0 cp .env.prod.example .env.prod ``` @@ -279,8 +341,8 @@ openssl rand -hex 32 Open `.env.prod` in an editor and replace every `change-me` value: -- Set `OPENCLOCKWORK_VERSION` to the exact version from the GitHub Release, - without the leading `v`. Never deploy `latest`. +- Set `OPENCLOCKWORK_VERSION=2.0.0` for this release. Use the same explicit + version for the API and web images; never deploy `latest`. - Put the first command's output into `POSTGRES_PASSWORD` and replace `change-me-database-password` inside `DATABASE_URL` with that exact same value. These two locations must match. @@ -355,7 +417,7 @@ All services should be healthy, the migrations should be current, and the health endpoint should return HTTP 200. Replace `8080` if `WEB_PORT` has been changed. The login form is intentionally empty in production at this point. -### 5. Create the first HR administrator +### 5. Choose Solo or Team and create the first account Run the interactive bootstrap command from the production host. Do not add `-T`: the command requires a terminal for its prompts. @@ -365,30 +427,51 @@ docker compose -f docker-compose.prod.yml --env-file .env.prod \ exec api node --import tsx prisma/create-admin.ts ``` -Enter the administrator's personnel number, name, email address, time model, -weekly hours, annual leave, start date, and optional holiday calendar. Defaults are shown in -square brackets and can be accepted with Enter. +The first prompt asks for `Solo` or `Team` (interactive default: `Solo`). -The command creates exactly one active `HRAdmin` and prints a random initial -password once. Store that password in the organisation's approved password -manager. The command refuses to run if any employee already exists, and it -never imports the demo seed. +- **Solo:** enter your first name, last name, and email. The command creates the + single owner; no personnel number, contract hours, leave entitlement, manager, + or work schedule is required. Personal targets, leave, hints, daily blocks, + GPS, and automatic break deductions start disabled. +- **Team:** enter the first administrator's personnel number, name, email, time + model, weekly hours, leave allowance, start date, and optional holiday preset. + Enter the organisation's actual values; the displayed defaults are not legal + or contractual recommendations. + +Both choices create exactly one active `HRAdmin` identity internally and print a +strong random initial password once. In Solo, the application presents that +identity as the **owner**, not as a separate HR department. Store the password +privately. Bootstrap refuses to run if any employee already exists and never +imports the demo seed. + +Unattended setup accepts explicit non-secret fields; use `--help` for the +options. For compatibility, an invocation with options but without `--mode` +defaults to **Team**. Merely appending `--mode Solo` or `--mode Team` does not +start interactive prompts: supply the required identity fields as well. ### 6. Sign in and replace the initial password 1. Open the configured public URL (or `http://localhost:8080` while testing directly on the host; use the configured `WEB_PORT` if it differs). 2. Sign in with the email address and generated initial password from step 5. -3. Open **Administration → Employees**, select the key action for your own - account, and set a new unique password of at least eight characters. -4. Sign out and sign in again with the new password before discarding the - initial password. +3. In **Settings → Password**, enter the current password and a new unique + password of at least 12 characters. The signed-in owner/administrator can + also review their name and email in Settings. +4. Password changes invalidate existing access and refresh sessions. Sign in + again with the new password before discarding the initial password. + +### 7. Finish setup for your mode -### 7. Finish the organisation setup +- **Solo:** the first login opens personal setup. Confirm the displayed working + timezone and either leave the optional policies off or configure the ones you + need. Complete setup, then create a customer/project or start tracking internal + work. Follow [the Solo walkthrough](docs/SOLO_MODE.md). +- **Team:** review the administrator's employee data, then create schedules, + employees, reporting relationships, projects, and assignments. Configure + terminal kiosks only if wanted. Follow [the Team walkthrough](docs/TEAM_MODE.md). -Review the new administrator's employee master data, then create the required -work schedules, employees, projects, and assignments through the administration -pages. Nothing from `prisma/seed.ts` belongs in a production database. +Nothing from `prisma/seed.ts` belongs in a production database. Switching mode +later is explicit and guarded; it is not a substitute for completing setup. ### 8. Protect the installation @@ -439,7 +522,10 @@ You can also support the project through ## Documentation - [Complete feature overview](FEATURES.md) -- [Roadmap: Solo mode, invoices, and CAUR-based agent billing](ROADMAP.md) +- [Operating modes: choose and switch safely](docs/OPERATING_MODES.md) +- [Solo: personal setup, customer work, reports, and recovery](docs/SOLO_MODE.md) +- [Team: employees, schedules, approvals, terminals, and reports](docs/TEAM_MODE.md) +- [Roadmap: billing, agent usage, and further internationalisation](ROADMAP.md) - [iPad terminal setup and operations (German)](docs/IPAD_TERMINAL_SETUP.de.md) - [Upgrade procedure](UPGRADING.md) - [Release process](RELEASING.md) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8dc3ae2..a616f4f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,264 @@ +# OpenClockwork v2.0.0 + +OpenClockwork 2.0 introduces two explicit ways to run the same application: +**Solo** for one person's work and customer time, and **Team** for established +employee, schedule, leave and approval workflows. This is a product-generation +major release, not a replacement of the existing Team application. + +Existing 1.4.0 installations can upgrade directly: their data and identities are +retained, they stay in Team mode, and no Solo owner is invented. The new Solo +contracts and deliberately stricter maintenance tools are described below; +existing Team requests are not universally required to adopt Solo-only fields. + +## Highlights + +- A focused Solo workspace with authenticated owner setup, personal overview, + timer, time history, calendar, customers, projects, reports and settings. +- One shared timer across browser sessions, atomic project switching, manual + entries, corrections with reasons, cancellation and auditable history. +- Stable automatic break deduction across a capture group: splitting work or + changing project does not restart the break threshold or deduct the same + break twice. Calculations retain sub-minute precision. +- Customer and internal project allocation, service orders, billable time, + filtered gross/break/net reports, CSV and printable customer statements. +- Optional, effective-dated personal targets, leave accounts and carry-over, + holiday dates, break rules, daily target blocks, GPS and frame/core-time hints. +- Stronger session validation, protected last-administrator changes and + conflict-aware updates, while preserving the existing Team data model's history. +- Separate public guides for + [choosing an operating mode](https://github.com/patrickschiller/openclockwork/blob/v2.0.0/docs/OPERATING_MODES.md), + [Solo mode](https://github.com/patrickschiller/openclockwork/blob/v2.0.0/docs/SOLO_MODE.md) + and [Team mode](https://github.com/patrickschiller/openclockwork/blob/v2.0.0/docs/TEAM_MODE.md). + +## Solo and Team workflows + +### A personal workspace without fabricated HR processes + +- A fresh Solo installation has one active owner. New Solo targets, leave + accounting, break deductions, daily blocks, GPS and personal hints start + disabled; enable only the rules you intend to use. Owner setup does not + require an employee-management matrix or a manager approval workflow. +- Start, switch and stop the personal timer, or enter a completed interval + manually. Future completed work and overlapping effective intervals are + rejected. Corrections require a reason; cancellation retains the record and + its history while removing its effect on totals. +- Personal days support free time, vacation, sickness and training, including + half-day boundaries, editing, cancellation and history. These are direct + personal records rather than requests sent to an imaginary approver. +- The interface supports English and German, light/dark themes, responsive + layouts, keyboard-accessible confirmation dialogs and explicit pending/error + states. Customer statements have a dedicated print layout, usable with the + browser's print or Save as PDF function. + +### Deliberate mode transitions + +- Team retains employee administration, work schedules, leave requests, + approvals, accounts and terminal workflows. Upgrade alone does not disable + them or convert past Team entries into Solo entries. +- Administrators preview a mode change before applying it. Open timers block + switching; entry into Solo also requires resolving other active employees, + pending requests/time approvals and active terminals. +- Transitions preserve identities and records. Eligible active projects are + assigned to the Solo owner without requiring a separate assignment step. + Switch to Team before adding another active employee. +- Access mode changes immediately. If the current working-timezone day already + contains effective work or time off, the accounting-mode change starts on + the following calendar day. Its effective date is recorded so switching + access mode does not rewrite that day's targets or leave treatment. +- Solo restrictions are enforced by the API, not merely hidden navigation: + Team/HR routes and another person's private data do not become available by + guessing a URL. + +## Personal rules, precision and historical accounting + +- Personal policies are versioned by effective date. Past policy changes are + rejected, and a day with recorded accounting activity is protected against + replacement by a new same-day policy. Future versions remain visible in + settings. Existing time entries retain their captured break rules. +- A weekly target is distributed over the configured working days. Work from + periods before target activation, or after its deactivation, is not silently + turned into overtime. Historical mode and policy versions determine which + work and targets belong in the account. +- Leave supports an annual base, explicit carry-over, optional carry-over + expiry, and reasoned positive or negative adjustments tied to an allowance + year. Only unused carry-over expires; consumed carry-over is not deducted + again, and year-specific carry/adjustments do not recur in the next year. +- Personal summaries account for half days, year boundaries and inherited + approved Team leave/absence history without double-counting overlapping + sources. Enabling an account does not apply today's rules indiscriminately + to earlier personal days. +- Core-time and frame checks are personal hints, not approval requirements. + They evaluate completed past days, omit open/current-day work and avoid + inventing missing attendance on empty or excused days. +- Capture groups use actual elapsed time. Their total automatic break is + shared proportionally across segments, including repeated splits and + project changes. Legacy ungrouped Team entries retain their existing + calculation behavior. Rounding is a presentation concern, so the sum of + rounded display rows can differ slightly from a separately rounded total. +- Reports clip intervals at local day and period boundaries using UTC + instants. The entry form rejects nonexistent local times and requires an + explicit UTC-offset choice for ambiguous repeated times around daylight + saving changes. + +## Customers, billable work and private exports + +- Customers can be linked to projects; internal work does not require a + customer. Projects have a default billable flag, service orders can override + it, and the selected billable value is recorded on each time entry. +- New Solo projects are assigned to their owner transactionally. Where a + project has active service orders, a booking must select an active order. + Archived customers/projects remain available in history but cannot receive + new bookings; archiving a running timer's target is blocked. A booked + project's customer cannot be silently reassigned. +- `GET /api/reports/solo` and `GET /api/reports/solo.csv` are owner-scoped and + filter by period, customer, project, service order, billable status or + unassigned work. They distinguish gross interval time, allocated breaks, + net working time and billable net time. +- Open timers, rejected entries and cancelled/voided work are excluded from + final totals. Filtering a segment does not recalculate its capture group's + break threshold from only the visible subset. +- Customer reports include customer-facing activities but omit internal + notes and location details. CSV exports use UTF-8 with a BOM, quoted + semicolon-delimited fields, timezone/period metadata and protection against + spreadsheet-formula interpretation of dangerous text prefixes. Print + layout omits navigation, filters and internal administrative controls. +- The billable flag classifies time only: it does not set a price, calculate + tax, create an invoice or establish a payment claim. + +## Authentication, concurrency and API compatibility + +- HTTP authentication and token refresh check the employee's current active + state, role and session version instead of relying on a stale role claim. + Password changes and administrative/recovery resets invalidate earlier + access and refresh tokens. Legacy tokens without a version are treated as + version zero and stop working after the employee's session version changes. +- Realtime connections reject refresh tokens, enforce access-token expiry and + check current employee/session/owner eligibility. Authorization is checked + again before broadcasts; invalid sessions are disconnected and Solo events + do not expose another employee's activity. HTTP remains the source of truth. +- Concurrent administrator changes cannot demote or deactivate the last active + administrator. Solo employee-management restrictions also apply inside + write transactions, not only at the controller boundary. +- Updates to existing Solo time entries, timer switches/stops, personal days + and installation settings use revision checks. Stopping a Solo timer also + identifies the open entry; Solo range allocation supplies the affected + entry IDs/revisions. Stale state returns a conflict instead of overwriting + a newer change. Clients should reload and ask the user to retry deliberately. +- Solo-only requirements do not make those fields mandatory for every legacy + Team operation. Integrators should use the regenerated + [OpenAPI contract](https://github.com/patrickschiller/openclockwork/blob/v2.0.0/apps/api/openapi.json), + handle authentication/conflict failures, and reauthenticate after password + changes rather than retrying a revoked session. +- Initial-owner bootstrap works only with an empty employee table. A separate + trusted-operator recovery command resets the existing active Solo owner's + password without replacing their identity or history; it is not a general + account-creation or mode-bypass mechanism. + +## Database migrations from 1.4.0 + +All five forward migrations run through normal `prisma migrate deploy`: + +| Migration | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `20260908120000_solo_mode` | Adds operating mode, installation settings/events, personal policy/day records, customers and time-entry audit history; adds project/customer and billable defaults, service-order inheritance, time-entry revision/void/capture-group/approval-mode fields, foreign keys and overlap/open-timer constraints. Existing installations receive a Team setting, not a Solo owner. | +| `20260908130000_solo_session_version` | Adds `Employee.authVersion` with default zero for session invalidation. | +| `20260908140000_solo_leave_versions` | Adds explicit carry-over, expiry, adjustment/reason and allowance-year fields to personal policy versions. | +| `20260908150000_solo_personal_windows` | Adds personal frame/core-time configuration and database validation of its stored shape. | +| `20260908160000_solo_required_target` | Requires a non-null, positive, bounded weekly target whenever the personal target feature is enabled. | + +Existing employees, times, leave records and identifiers are retained. Historical +time entries receive neutral Solo metadata: non-billable, revision zero, no void +timestamp, no capture group and no Solo approval-mode classification. The new +migrations do not replace their existing 1.4.0 break snapshots or calendars. +No intermediate 1.5.0 release or demo reseed is required. + +## Deployment, upgrade and rollback + +- Read the [upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v2.0.0/UPGRADING.md) + before applying 2.0.0. Keep the current database/attachment volume identities, + credentials and explicit working `TZ`; do not apply UTC merely because it + is the default for a new installation. +- Pause writers and take a consistent database/attachment backup before the + first upgraded API starts. Use unique timestamped destinations outside the + checkout and Docker storage, protect configuration/secrets, encrypt backups, + keep an off-machine copy, and test restoration into a separate empty target. +- Deploy matching API and web images, API first when deploying separately; + wait for `/api/health` to report `2.0.0`. Existing PWA/browser tabs may need + reopening after an update. Normal production startup migrates, never seeds + or resets, but an image change can therefore trigger a schema migration. +- Do not run `docker compose down -v`, volume-pruning/removal commands, Prisma + resets or demo seeds against production. Named volumes alone are not a + backup or a deletion lock; external volumes protect against Compose-managed + deletion, not a Docker administrator or loss of the host. +- A new production Solo installation should have its own project, credentials, + network and persistent volumes, with no published database port. Existing + development or acceptance data must not be erased to manufacture an empty + installation. Production backup scheduling, encryption, off-machine storage + and recovery policy remain operator responsibilities. +- Forward migrations are not automatically reversible. This release does not + declare a migrated 2.0.0 database safe to run with 1.4.0 binaries. For a full + rollback, stop writers and restore the matching pre-upgrade database, + attachments and configuration to an isolated recovery target before a + deliberate cutover; retain the failed state for investigation. + +## Deliberately stricter maintenance tools + +Development/test/demo commands now require explicit non-production targets and +operation-specific opt-ins. E2E runs must identify a matching test database +explicitly; seed/reset commands reject production execution and unclassified +database names. Existing scripts that relied on a generic database name or +inherited production `DATABASE_URL` need updating rather than weakening the +guard. In particular, a demo-reset job running with `NODE_ENV=production` is +not a supported exception. These are intentional maintenance-tool compatibility +changes, not a new requirement for ordinary Team HTTP requests. + +Development Compose no longer seeds automatically and starts with demo mode +disabled. The Azure template can select a separate classified demo database +and run its explicitly enabled maintenance job in a non-production runtime; +the API remains in production mode. Existing databases are not renamed. + +See the exact configuration and migration steps in the +[maintenance section of the upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v2.0.0/UPGRADING.md#development-test-and-demo-maintenance). +The guards provide defense in depth; they do not make a privileged database +credential or destructive Docker command safe to share with a development shell. + +## Validation and known limits + +Validation covers automated domain/API/UI regressions plus separate Docker +upgrade/restore and representative browser workflows, including grouped break +deduction, cross-tab timers, revisions, policy history, leave expiry, timezone +boundaries and customer report output. Print-to-PDF output was also checked. +This is not a claim that every device/browser, operational recovery procedure +or long-running production scenario has received a universal manual sign-off. + +- CAUR agent-usage accounting, automated model/token/runtime collection, + invoice creation, prices, tax calculations and combined human/agent billing + remain outside this release. +- Solo is one owner in one installation, not multi-tenant SaaS or a customer + self-service portal. Team remains the mode for multiple active employees. +- One deployment working timezone governs day and policy boundaries; there + are no independent per-employee working timezones. Changing display language + does not change the working calendar or timezone. +- Maintained holiday presets cover German states. Other calendars can use + explicit custom dates, supplied for each applicable year. There is no claim + of automatic worldwide employment-law or accounting compliance. +- Optional targets, breaks and hints are configurable behavior, not legal + advice. Current-day core hints are intentionally deferred, and the overview + account cards show the current year through today rather than an arbitrary + historical/future account-date selector. +- CSV/PDF output is a time statement, not an invoice. Browser printing/download + behavior and access to local attachments still depend on the deployed + environment. No automatic off-site backup or zero-data-loss guarantee is + enabled by choosing Solo mode. + +## Docker images + +- `ghcr.io/patrickschiller/openclockwork-api:2.0.0` +- `ghcr.io/patrickschiller/openclockwork-web:2.0.0` + +Pin a release version or verified image digest; do not use `latest` or a mutable +development image tag for unattended production upgrades. + # OpenClockwork v1.4.0 ## Highlights diff --git a/RELEASING.md b/RELEASING.md index e4457d0..bc5d0cf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,10 @@ # Releasing OpenClockwork -Only publish a release from a green `main` commit. Stable releases follow -semantic versioning and use matching metadata in five places: +Only publish a release from a green `main` commit. Version 2.0.0 is an explicitly +chosen product-generation milestone introducing distinct Solo and Team modes; +it does not imply that every 1.4 integration or installation is incompatible. +Subsequent releases follow semantic versioning according to their documented +public-contract impact. Use matching metadata in five places: - `package.json`: `MAJOR.MINOR.PATCH` - `apps/api/src/main.ts`, `apps/api/src/generate-openapi.ts`, and the health DTO @@ -30,13 +33,14 @@ annotated tag on the exact merge commit and push it: ```bash git switch main git pull --ff-only -git tag -a v1.4.0 -m "OpenClockwork v1.4.0" -git push origin v1.4.0 +git tag -a v2.0.0 -m "OpenClockwork v2.0.0" +git push origin v2.0.0 ``` The release workflow verifies the tag and notes, runs Nx and API end-to-end tests, publishes the API and web images to GHCR, and finally creates a GitHub -Release. GitHub's generated changelog is appended to the curated notes using +Release. Only the current section of `RELEASE_NOTES.md` is published; historical +release notes remain in the repository. GitHub's generated changelog is appended to the curated notes using the categories in `.github/release.yml`. For the first publication of each GHCR package, verify its visibility is @@ -46,8 +50,8 @@ pulled anonymously by self-hosted installations. ## Verify the published release ```bash -docker pull ghcr.io/patrickschiller/openclockwork-api:1.4.0 -docker pull ghcr.io/patrickschiller/openclockwork-web:1.4.0 +docker pull ghcr.io/patrickschiller/openclockwork-api:2.0.0 +docker pull ghcr.io/patrickschiller/openclockwork-web:2.0.0 ``` Confirm that the GitHub Release is marked latest, contains the curated upgrade diff --git a/ROADMAP.md b/ROADMAP.md index db41bb4..707c2be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,95 +1,95 @@ # OpenClockwork Roadmap -OpenClockwork is evolving from team time and attendance into a self-hosted -workspace for personal work, customer billing, and coding-agent usage accounting. -The project remains public and Apache-2.0 licensed. +OpenClockwork is a public, Apache-2.0 self-hosted application for personal work +and Team time and attendance. Version **2.0.0** makes Solo and Team first-class +operating modes. Customer invoicing and coding-agent usage accounting remain +future work; a billable time flag does not mean those features already exist. -This roadmap describes intended scope and delivery order, not a release-date -commitment. **Solo mode, invoice creation, and CAUR integration are planned and -are not implemented yet.** See [FEATURES.md](FEATURES.md) for the current -application and [README.md](README.md) for installation. +This roadmap expresses priorities and intended scope, not release dates or +promises of regulatory compliance. See [FEATURES.md](FEATURES.md) for implemented +capabilities, [the mode comparison](docs/OPERATING_MODES.md) for current +boundaries, and [README.md](README.md) for release installation. ## Delivery order -| Stage | Scope | Status / dependency | -| ----- | -------------------------------------------- | ---------------------------------------------- | -| 1 | Country-neutral configuration and defaults | Included in v1.4.0 | -| 2 | Complete Solo mode | Planned; builds on stage 1 | -| 3 | Customer billing and invoice creation | Planned; available to Solo and team workspaces | -| 4 | CAUR usage import, attribution, and review | Planned; can progress alongside stage 3 | -| 5 | Agent billing and combined customer invoices | Planned; requires stages 3 and 4 | - -## 1. Country-neutral foundation - -The application must not infer a country's work rules from the user's language. -The initial foundation includes: - -- English and German UI, browser-language detection, English fallback, and - regional date formatting. -- Explicit installation and terminal timezones, with UTC as the neutral - deployment fallback. -- Optional holiday calendars and custom holiday dates for national, regional, - and company calendars. Existing German state calendars remain optional presets. -- Configurable schedule break thresholds and deductions, without automatically - imposing the previous German policy on new schedules. -- Explicit leave allowances and working-day masks. New employee forms and - bootstrap no longer assume a 30-day entitlement. -- Forward migration of existing calendars and schedules, plus stored break - policy snapshots so later policy edits do not rewrite historical deductions. - -Further internationalisation work remains planned: - -- [ ] Per-workspace and per-employee working timezones, including midnight, - daylight-saving changes, overnight shifts, and cross-zone reporting. The - current API uses one deployment timezone for day and schedule boundaries. -- [ ] Reusable, maintained regional calendars with import/update workflows and - visible coverage years; custom date lists currently require each year's dates. -- [ ] Additional UI translations, regional week-start and number preferences, - and an English equivalent of the German iPad operations guide. -- [ ] Versioned schedule/leave policies where effective dates are needed for - historical target hours and leave calculations, beyond break snapshots. -- [ ] Currency, address, tax, and invoice-format settings selected independently - from language as part of billing. No country-wide compliance claim is implied. - -## 2. Complete Solo mode - -Solo mode is a first-class workflow for freelancers, independent consultants, -and people tracking their own work. It must work without creating a fictional -manager, HR department, or employee approval chain. - -- [ ] **Setup and identity:** choose Solo or Team during setup; create one owner - account and a personal workspace with timezone, language, working days, optional - weekly target, holidays, and optional leave tracking. Reuse secure login, - password management, backup, and self-hosting workflows. -- [ ] **Focused navigation:** a personal dashboard, timer/bookings, calendar, - customers, projects, reports, and settings. Show billing and agent usage when - those capabilities become available. Team administration, substitutes, approval - inboxes, and kiosk setup should not be required in the Solo workflow. -- [ ] **Time capture:** start/stop a timer, add and correct manual entries, split - work between projects, edit activity descriptions, and review daily/weekly - totals. Keep the mobile PWA experience. GPS and the existing daily-target block - remain optional and explicitly configured. -- [ ] **Personal policies:** optional work targets, break deduction, time-off and - overtime tracking; support working without attendance obligations or a fixed - schedule. Use direct owner actions for personal bookings and corrections, - preserving an audit history without routing them through employee approvals. -- [ ] **Customer and project work:** customer records, projects/service orders, - billable versus non-billable entries, estimates/budgets, and customer-facing - activity descriptions. Rates will feed the shared invoicing foundation. -- [ ] **Reports and exports:** personal productivity and project totals, customer - timesheets, period filters, and exportable billing evidence. Distinguish raw - tracked time, deductible breaks, and billable time. -- [ ] **Transition to a team:** invite additional people and enable Team mode - without losing customers, projects, bookings, settings, or invoice references. - Map the Solo owner to an explicit administrative role; define who can see and - approve existing records. Returning to Solo must require resolving active team - members and pending workflows first. -- [ ] **Acceptance coverage:** complete setup → track work → assign to a customer - → review/export flow in both UI languages, optional-policy behaviour, owner - access boundaries, and migration of an existing one-person installation. - -**Done when:** one person can operate the application end to end without an HR -workflow, and can later enable team collaboration while preserving their data. +| Stage | Scope | Status / dependency | +| ----- | ---------------------------------------------- | --------------------------------------- | +| 1 | Country-neutral configuration and defaults | Included in 1.4.0; retained in 2.0.0 | +| 2 | Complete personal Solo workflow alongside Team | Included in 2.0.0 | +| 3 | Customer billing and invoice creation | Planned; intended for Solo and Team | +| 4 | CAUR usage import, attribution, and review | Planned; can progress alongside stage 3 | +| 5 | Agent billing and combined customer invoices | Planned; depends on stages 3 and 4 | + +Version 2.0 deliberately marks a new product generation, not an invented +breaking Team API or a required data reset. Existing installations migrate +forward and remain Team. Future compatibility changes follow semantic +versioning and are documented in release notes and upgrade instructions. + +## 1. International foundation and remaining work + +Implemented foundations include: + +- [x] German and English UI, browser-language detection, English fallback, and + locale-aware display formatting. +- [x] Explicit installation working timezone and independently configured kiosk + display timezone, with UTC as the neutral deployment default. +- [x] Optional holiday calendars and custom dates; German regional calendars + remain presets rather than defaults selected by language. +- [x] Configurable break thresholds and deductions, without imposing an + automatic break policy on newly created schedules. +- [x] Explicit leave allowances and working-day masks, with no assumed annual + leave entitlement for new employee records. +- [x] Preservation of existing calendars/schedules and stored break evidence. +- [x] Effective-dated personal policies and yearly leave information for Solo, + including accounting boundaries during mode transitions. + +Remaining internationalisation work: + +- [ ] Multiple simultaneous employee/workspace working timezones. The current + API uses one installation timezone for working-day and schedule boundaries. +- [ ] Maintained regional calendar imports/updates with visible coverage years; + custom date lists require operators to maintain the periods in use. +- [ ] Additional UI languages, configurable regional week-start preferences, and + an English equivalent of the German iPad operations guide. +- [ ] Broader effective-dated Team schedule and leave-policy administration, + beyond existing break snapshots and the new Solo policy versions. +- [ ] Currency, address, tax, and invoice-format settings independent of UI + language, as part of a future billing feature. + +## 2. Solo and Team in 2.0.0 + +[The Solo guide](docs/SOLO_MODE.md) documents the shipped personal workflow. +[The Team guide](docs/TEAM_MODE.md) explains organisation setup and daily use. + +- [x] Explicit first-owner bootstrap, authenticated setup, profile/password + management, and local recovery of an existing active Solo owner. +- [x] Personal Overview, Times, Calendar, Customers, Projects, Reports, and + Settings navigation without Team approval or terminal screens. +- [x] Live timer, manual intervals, direct reasoned corrections/cancellations, + splitting, running project changes, revisions, and audit history. +- [x] Optional targets, leave accounting, holiday dates, break rules, personal + time-window hints, daily blocks, and GPS. +- [x] Customer/internal projects, service orders, planned-hour budgets, billable + classification, private notes, and protected archive/delete operations. +- [x] Owner reports with period/allocation filters, gross/break/net/billable-net + distinctions, CSV output, and browser print/PDF. +- [x] Guarded mode previews and explicit transitions preserving history and + defining accounting-effective dates. +- [x] Existing Team workflows retained, with existing installations remaining + Team after upgrade. +- [x] Automated domain/API/UI regression coverage and local Docker acceptance + work. Browser file/print handoffs and operator-specific rules still require + validation in the actual deployment; see release notes for scope. + +Follow-up work, not part of this release: + +- [ ] Shared Team customer management and billable reporting beyond the current + Solo-only screens, with explicit billing permissions. +- [ ] Invitations and guided onboarding of additional team members. Team + currently uses administrator-created employee accounts. +- [ ] Rate management and invoice linking, delivered with the billing foundation. +- [ ] Further device/PWA coverage and any additional export delivery formats. + Online-first operation is the current contract, not an offline write queue. ## 3. Customer billing and invoice creation @@ -129,9 +129,10 @@ customer invoice from tracked work and follow it through payment or correction. [CAUR — Coding Agent Usage Record](https://github.com/patrickschiller/caur) defines a vendor-neutral usage record for a completed or interrupted coding-agent -run. It is currently a **v0.1 draft**, not an invoice format or pricing catalogue. -OpenClockwork will consume these records and provide attribution, review, and -billing around them. The integration must follow the +run. It is a separate specification project, not an invoice format or pricing +catalogue, and is not imported by OpenClockwork 2.0.0. A future integration should +consume explicitly supported schema versions and provide attribution, review, +and billing around them. Its design should follow the [CAUR specification](https://github.com/patrickschiller/caur/blob/main/SPEC.md) and [JSON Schema](https://github.com/patrickschiller/caur/blob/main/schema/caur-v0.1.schema.json). diff --git a/UPGRADING.md b/UPGRADING.md index 1d4216e..7506f8f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,8 +1,16 @@ # Upgrading OpenClockwork -OpenClockwork releases use semantic versioning. Read the release notes for the -target version and every skipped version before upgrading. A release may call -out required intermediate versions or manual steps. +OpenClockwork 2.0.0 is a deliberately chosen product-generation milestone; +its major version does not imply that every 1.4.0 Team integration is +incompatible. Subsequent releases follow semantic versioning according to +their documented compatibility impact. Read the release notes for the target +version and every skipped version before upgrading. A release may call out +required intermediate versions or manual steps. + +For 2.0.0, also read [operating modes](docs/OPERATING_MODES.md), +[Solo mode](docs/SOLO_MODE.md) and [Team mode](docs/TEAM_MODE.md). This major +version introduces Solo alongside the existing Team product. It does not +require a fresh database or an automatic conversion to Solo. The production Docker Compose stack keeps PostgreSQL data in the named volume `openclockwork-db-data-prod` and local request attachments in @@ -10,26 +18,175 @@ The production Docker Compose stack keeps PostgreSQL data in the named volume containers without deleting either volume. Keep `OPENCLOCKWORK_DB_VOLUME` and `OPENCLOCKWORK_ATTACHMENTS_VOLUME` unchanged in `.env.prod` across upgrades. +The example volume names are not a backup or a deletion lock. Its named volumes +are Compose-managed unless you explicitly configure them as external. A custom +deployment can use separately provisioned `external: true` volumes to keep +Compose from managing their deletion, but this does not protect against manual +volume removal, pruning unused volumes, Docker data loss or host failure. Do +not replace a populated installation's volume with a new name during an upgrade. + +## Required for 2.0.0 + +### Existing 1.4.0 installations + +- Upgrade directly from 1.4.0; no intermediate 1.5.0 release or reseed is needed. + Existing installations remain **Team**, with setup marked complete and no + invented Solo owner. Employee, project, time, leave and terminal identities + and existing historical policy snapshots are retained. +- **Retain the installation's explicit working `TZ`.** For example, keep + `Europe/Berlin` if that is the timezone used by the existing data. A new + deployment's UTC default is not an instruction to change an existing one. + Keep the equivalent API/job timezone setting in Azure as well. +- The new time-entry metadata starts neutral: `billable=false`, `revision=0`, + `voidedAt=null`, `captureGroupId=null` and `approvalMode=null`. Existing Team + work is not relabelled as self-approved Solo work or retrospectively grouped + for a different break calculation. +- Preserve the same application secrets unless deliberately rotating them. + Password changes and administrator/recovery resets invalidate the affected + employee's previous access/refresh sessions; plan to sign in again. +- New Solo revisions and timer identifiers are mode-specific requirements, + not newly mandatory fields for every existing Team request. Check the + [OpenAPI contract](apps/api/openapi.json) for the operations your integration + uses. Handle revoked sessions and revision conflicts explicitly. + +### Five forward migrations + +The upgraded API runs the following pending migrations before serving traffic: + +| Migration | Operational effect | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `20260908120000_solo_mode` | Adds installation mode/settings/events, personal policies/days, customers and time-entry audit records; project/customer and billable defaults; service-order billable inheritance; time-entry revision, void, capture-group and approval-mode metadata. Updates constraints to account for voided work and initializes existing installations as Team. | +| `20260908130000_solo_session_version` | Adds the employee session version with a zero default; older versionless tokens are interpreted as version zero until a password/session-version change invalidates them. | +| `20260908140000_solo_leave_versions` | Adds allowance-year-specific carry-over, expiry and reasoned adjustments to Solo policy versions. | +| `20260908150000_solo_personal_windows` | Adds personal frame/core-time settings and database checks for their stored representation. | +| `20260908160000_solo_required_target` | Tightens the target constraint: an enabled personal target requires a non-null value greater than zero and no more than 10,080 weekly minutes. | + +These are forward migrations, not a database replacement. Do not edit or delete +already applied migration files. If a development/preview installation contains +invalid custom data and a constraint fails, stop the upgrade, inspect the +failure and correct it deliberately with a recoverable plan. Do not drop the +constraint or reset the database to make startup appear successful. + +### Choosing Solo without losing Team history + +An administrator can explicitly preview and request a mode change after the +upgrade. Open timers block a transition; entering Solo also requires resolving +other active employees, pending requests/time approvals and active terminals. +Existing records and IDs remain. Eligible active projects are assigned to the +owner, and the first personal policy for an existing worker may inherit their +current target/leave/calendar/break settings; review it before recording more +work. Fresh Solo defaults, by contrast, leave optional features disabled. + +The live access mode changes immediately. Accounting changes use an explicit +effective date; if today's working-timezone day already has effective work or +time off, the change starts on the next calendar day. Personal rule versions +also protect past/recorded days. Do not change timestamps or system clocks to +bypass that protection. Switch to Team before adding another active employee. + +Only run initial-owner/administrator bootstrap on an empty employee table. +It is not an upgrade or recovery step for an existing installation. If creating +a separate new production Solo installation, provision its own database, +credentials, network and persistent volumes rather than deleting a development +or acceptance database. Use the normal Solo bootstrap, not a demo seed or a +historical test fixture. Keep the production database off published host ports. + ## 1. Prepare and back up -Run these commands from the directory that contains the existing `.env.prod`. -Keep backups outside Docker volumes and test their restoration regularly. +Record the current API/web image versions or digests, PostgreSQL major version, +Compose project name, exact Compose file set, volume names, working timezone and +attachment backend. Resolve the actual running resources before issuing any +command; a different project name or omitted override can select a different +installation. Production credentials must not be copied into the development +checkout's `.env`, E2E configuration or a globally exported `DATABASE_URL`. + +Choose a protected backup directory **outside both the source checkout and +Docker-managed storage**. Use unique timestamped backup sets; do not overwrite +the previous recovery point. Database dumps contain personal data, and copied +configuration contains secrets. Restrict filesystem access, encrypt retained +backups, keep an off-machine copy and store recovery keys separately. The +application does not automatically configure those services for you. + +For a consistent database/attachment pair, pause all writers: browser/API +traffic, scheduled jobs, imports, integrations and any direct database writers. +The example below stops the standard web/API services but leaves PostgreSQL +running for its consistent logical dump. Stop any additional writer separately. +Do not treat a running timer as automatically stopped by a container restart; +coordinate the maintenance window with users. + +The following commands assume the standard local attachment mount +`/app/data/attachments`. Run them in a dedicated shell, supplying the **existing** +deployment's values; do not use development/test Compose files. If multiple +Compose files are required, include all of them in the `oc` function in the same +order as the deployed stack. ```bash -mkdir -p backups - -docker compose -f docker-compose.prod.yml --env-file .env.prod \ - exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom' \ - > backups/openclockwork-before-upgrade.dump - -docker compose -f docker-compose.prod.yml --env-file .env.prod \ - exec -T api tar -czf - -C /app/data attachments \ - > backups/openclockwork-attachments-before-upgrade.tar.gz +set -eu +umask 077 + +: "${OC_PROJECT:?Set the existing Compose project name}" +: "${OC_COMPOSE_FILE:?Set the absolute path to the existing production Compose file}" +: "${OC_ENV_FILE:?Set the absolute path to its private environment file}" +: "${OC_BACKUP_ROOT:?Set an existing protected absolute directory outside the checkout and Docker storage}" + +oc() { + docker compose -p "$OC_PROJECT" -f "$OC_COMPOSE_FILE" \ + --env-file "$OC_ENV_FILE" "$@" +} + +oc ps +oc images +oc_backup_dir="$(mktemp -d "$OC_BACKUP_ROOT/openclockwork-$(date -u +%Y%m%dT%H%M%SZ)-XXXXXX")" + +oc stop web api + +oc exec -T db sh -c \ + 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom' \ + > "$oc_backup_dir/database.dump" + +# The one-off command only archives files: it does not start the API, +# run migrations or start its dependencies. Existing images are reused. +oc run --rm --no-deps --pull never -T --entrypoint tar api \ + -czf - -C /app/data attachments \ + > "$oc_backup_dir/attachments.tar.gz" + +# These files contain secrets. Keep the whole backup set private. +cp "$OC_ENV_FILE" "$oc_backup_dir/deployment.env" +oc config > "$oc_backup_dir/compose.resolved.yml" +oc images > "$oc_backup_dir/images.txt" + +# Readability checks are useful, but do not replace a restore test. +oc exec -T db pg_restore --list < "$oc_backup_dir/database.dump" \ + > "$oc_backup_dir/database-toc.txt" +tar -tzf "$oc_backup_dir/attachments.tar.gz" > /dev/null ``` If `STORAGE_BACKEND=azure-blob` is configured, back up the Azure container -according to the organisation's storage policy instead of using the attachment -command above. +with its provider-supported backup/versioning procedure **instead of** the +local attachment command. Preserve a matching database recovery point and +keep all relevant writers paused while establishing the pair. Back up any +external secret references and additional PostgreSQL roles required for recovery; +`pg_dump` is a database dump, not a complete cluster/role or point-in-time backup. + +Do not proceed after a failed dump, archive or validation. The files produced +above are local, unencrypted backup material until protected by your chosen +encryption/backup system. Complete the encrypted off-machine copy and record +checksums, versions and capture time. Retain earlier backup generations. + +### Test restoration before relying on the backup + +Restore into a **separate empty database/volume and attachment target**, using +the compatible PostgreSQL major version and the pre-upgrade application image. +Do not test by overwriting the live database. Restore the required database +owner/roles, run `pg_restore` with error checking, then restore the matching +attachment archive with the correct application ownership/permissions. Keep +this recovery environment private and disconnected from real email, terminals, +scheduled jobs and integrations. + +Check migrations, IDs and representative full record values, time/leave +history, policy snapshots, logins and attachment readability. Record which +backup set was restored and the outcome. A successful dump exit code alone +does not establish recoverability. Encrypted backups are useful only if their +decryption keys and configuration can also be recovered. ## Required when adding QR terminal support @@ -105,25 +262,82 @@ Review calendars and break policies after the upgrade. Custom holidays are explicit dates rather than recurring rules: supply each relevant year. The API still uses one deployment working timezone for day and schedule boundaries. +## Development, test and demo maintenance + +Version 2.0.0 deliberately tightens repository maintenance commands. This is +separate from the data-preserving production `prisma migrate deploy` startup. +The project guards fail closed for `NODE_ENV=production`, unclassified database +names, unsupported/ambiguous connection settings and non-`public` schemas. +They do not control a database administrator, a directly invoked Prisma/SQL +command or Docker volume deletion. + +| Command / operation | Required non-production configuration | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| API E2E | `E2E_DATABASE_URL` is required. Its database must be `openclockwork_test` or that name followed by underscore-separated lowercase alphanumeric suffixes, such as `openclockwork_test_upgrade`. If `DATABASE_URL` is also set, it must identify the same canonical connection. | +| E2E database administration | `E2E_ADMIN_DATABASE_URL` is optional; otherwise it is derived from the test connection using the `postgres` database. An explicit admin connection must use the same host/port and the `postgres` database. | +| `pnpm db:seed` | A classified `openclockwork_dev`, `openclockwork_demo` or `openclockwork_test` database, optionally with lowercase alphanumeric underscore-separated suffixes, plus `OPENCLOCKWORK_SEED_CONFIRM_DATABASE` exactly matching its database name. | +| `pnpm db:reset` | The same classified target rules, plus `OPENCLOCKWORK_RESET_CONFIRM_DATABASE` exactly matching its database name. This remains destructive, accepts no pass-through CLI flags and no longer seeds automatically after resetting. | +| `pnpm db:demo-reset` | The classified target and reset confirmation above, plus `DEMO_RESET_ENABLED=true` and `DEMO_RESET_CONFIRMATION=DELETE-AND-RESEED-OPENClockwork-DEMO`. Its subsequent seed uses the already verified target. | + +E2E checks run before application initialization and again before table +truncation, including a check of the actual connected database/schema. Seed +and demo-reset also verify their connected database before writes. Nevertheless, +a name/confirmation is only a guardrail: do not put real data into a disposable +test/demo database or expose production credentials to a test process. + +If an existing local development database is named simply `openclockwork`, it +is intentionally not a valid destructive maintenance target. Do not rename, +reset or repurpose it to satisfy a check. Keep it and provision a separate +explicitly disposable dev/test/demo database when needed. Remove inherited +production `DATABASE_URL` values from the test shell and set the test connection +per invocation. No default test URL is assumed anymore. + +The development Compose stack no longer seeds automatically and defaults +`DEMO_MODE` to `false`. Creating synthetic demo data is now an explicit, +separately confirmed maintenance operation on a disposable database. + +An older Azure demo-reset job using `NODE_ENV=production` and a generic +`openclockwork` database will now refuse to run. Disable or replace that job +with a deliberately isolated non-production demo lifecycle and its explicit +opt-ins. Do not weaken the production environment or rename a production +database to make a reset job pass. Review CI, local scripts and scheduled demo +jobs before relying on their next run. Normal production upgrades do not +require any seed/reset opt-in. + +The Azure template's optional `postgresDatabaseName` keeps the existing generic +name as its default; it does not rename existing databases. For a deliberately +new disposable demo deployment, follow the [Azure deployment guide](infra/azure/README.md) +to select a classified name and opt in to its maintenance job. Only that job +runs in a non-production runtime; the API retains its production runtime. + ## 2. Select and pull the release Set `OPENCLOCKWORK_VERSION` in `.env.prod` to the exact version from the GitHub -Release, for example `1.4.0`. Do not use `latest` for a controlled production -upgrade. +Release, **`2.0.0` for this upgrade**, only after preserving the old configuration +in the backup set. Pin matching API/web versions or verified digests. Do not use +`latest`, a mutable `:local` image, or a source-build override for an unattended +production upgrade. Keep the same PostgreSQL major version; a PostgreSQL major +upgrade is a separate procedure, not an application-image update. + +Use the `oc` function and exact deployment configuration selected in step 1: ```bash -docker compose -f docker-compose.prod.yml --env-file .env.prod pull +oc pull api web ``` ## 3. Apply the update ```bash -docker compose -f docker-compose.prod.yml --env-file .env.prod up -d +oc up -d ``` The API waits for PostgreSQL and runs `prisma migrate deploy` before starting. Prisma records applied migrations in `_prisma_migrations` and skips them on subsequent starts. Production startup never seeds or resets the database. +If API/web are deployed independently, deploy the API first, wait for its +health endpoint to report `2.0.0`, then deploy the matching web image. The web +startup version check is not a promise of zero-downtime mixed-version operation; +keep additional writers paused until verification completes. Do not run any of the following during an upgrade: @@ -132,33 +346,65 @@ docker compose down -v prisma migrate reset pnpm db:reset pnpm db:demo-reset +pnpm db:seed +docker volume prune --all ``` +Do not remove existing volumes manually or change their names. Neither a clean +development database nor a successful test run authorizes deletion of an +existing installation. Keep production isolated from demo/reset workflows. + ## 4. Verify the installation ```bash -docker compose -f docker-compose.prod.yml --env-file .env.prod ps +oc ps -docker compose -f docker-compose.prod.yml --env-file .env.prod \ - exec -T api ./node_modules/.bin/prisma migrate status +oc exec -T api ./node_modules/.bin/prisma migrate status -curl --fail http://localhost:${WEB_PORT:-8080}/api/health +oc exec -T api wget -qO- http://127.0.0.1:3000/api/health ``` -Sign in and verify a known employee, an existing time entry, and any configured -attachment storage before considering the upgrade complete. +Confirm `2.0.0` through the public web/proxy health route as well as the +container check. Sign in and verify the actual deployed mode, a known employee, +an existing time entry, historic break totals, leave balances and configured +attachment storage. A 1.4.0 upgrade must still be Team unless an administrator +has explicitly switched it. Reopen old PWA/browser tabs if they show a cached +bundle, and verify that exports/printing work in the browsers you operate. + +For an intentional Solo transition, verify the owner, inherited or deliberately +disabled personal rules, customer/project eligibility, timer state and history. +Confirm existing records remain accessible in their intended mode. Do not +substitute a synthetic fixture's expected values for the installation's real +baseline. Resume scheduled jobs and integrations only after the selected +backup, migration, identity and functional checks succeed. ## Rollback Application containers can only be changed back to an earlier `OPENCLOCKWORK_VERSION` when the release notes explicitly declare the database compatible with that version. Forward migrations are not automatically -reversed. - -For a full rollback, stop application traffic, restore the database backup and -attachment backup, set the previous version in `.env.prod`, and start the stack -again. Restoring overwrites current data, so follow the organisation's incident -and backup procedures rather than attempting an ad-hoc reverse migration. +reversed. **2.0.0 does not declare its migrated database backward-compatible +with 1.4.0 application binaries.** Do not simply point an older API at it. + +For a full rollback: + +1. Stop all writers and record the failure, current images and migration state. + Preserve the failed database/attachments; do not erase the evidence. +2. Restore the matching pre-upgrade database, attachments and protected + configuration into a separate empty recovery target, using the previous + application version and a compatible PostgreSQL version. Keep its external + integrations disabled while checking it. +3. Verify identities, representative historical values, balances and attachment + access. Confirm the recoverable backup point and explicitly account for any + changes made after that point; restoring does not merge later work. +4. Perform a deliberate, documented cutover to the verified recovery target. + Only overwrite or delete an existing target under an explicitly authorized + incident plan. Keep the original state and older backups until the recovery + has been accepted and retention requirements allow cleanup. + +This procedure is not automatic rollback or point-in-time recovery. Backup +frequency, off-machine storage, encryption, retention, alerting and recovery +objectives must be configured and tested by the operator. ## Migration policy for contributors diff --git a/apps/api-e2e/package.json b/apps/api-e2e/package.json index 2abc99b..3dbf31b 100644 --- a/apps/api-e2e/package.json +++ b/apps/api-e2e/package.json @@ -7,6 +7,13 @@ "api" ], "targets": { + "test-ops": { + "executor": "nx:run-commands", + "options": { + "command": "node --test ops/*.test.mjs", + "cwd": "." + } + }, "e2e": { "executor": "@nx/jest:jest", "outputs": [ diff --git a/apps/api-e2e/src/api/create-admin.e2e.spec.ts b/apps/api-e2e/src/api/create-admin.e2e.spec.ts index f9bcec6..e48a14a 100644 --- a/apps/api-e2e/src/api/create-admin.e2e.spec.ts +++ b/apps/api-e2e/src/api/create-admin.e2e.spec.ts @@ -81,6 +81,41 @@ describe('Initial administrator command', () => { expect(await ctx.prisma.employee.count()).toBe(1); }); + it('bootstraps a Solo owner without contract or personal-number arguments', async () => { + const result = await runCreateAdmin([ + '--mode', + 'Solo', + '--first-name', + 'Alex', + '--last-name', + 'Example', + '--email', + 'solo@example.test', + ]); + expect(result.code).toBe(0); + const owner = await ctx.prisma.employee.findUniqueOrThrow({ + where: { email: 'solo@example.test' }, + }); + expect(owner.personalNo).toBe('OWNER'); + expect(Number(owner.weeklyHours)).toBe(0); + expect( + await ctx.prisma.installationSettings.findUnique({ where: { id: 1 } }), + ).toMatchObject({ + mode: 'Solo', + ownerEmployeeId: owner.id, + setupCompleted: false, + }); + expect( + await ctx.prisma.soloPolicy.findFirst({ + where: { employeeId: owner.id }, + }), + ).toMatchObject({ + targetEnabled: false, + leaveEnabled: false, + gpsEnabled: false, + }); + }); + it('validates command-line input before writing anything', async () => { const result = await runCreateAdmin([ ...ADA_ARGS.slice(0, -3), diff --git a/apps/api-e2e/src/api/project-session-boundary.e2e.spec.ts b/apps/api-e2e/src/api/project-session-boundary.e2e.spec.ts new file mode 100644 index 0000000..0c91dcc --- /dev/null +++ b/apps/api-e2e/src/api/project-session-boundary.e2e.spec.ts @@ -0,0 +1,180 @@ +import { ProjectsService } from '../../../api/src/app/projects/projects.service'; +import { CustomersService } from '../../../api/src/app/customers/customers.service'; +import { INSTALLATION_LOCK } from '../../../api/src/app/installation/installation.service'; +import type { JwtUser } from '../../../api/src/app/auth/jwt.strategy'; +import { + createTestApp, + seedEmployee, + seedProject, + type TestContext, +} from '../support/test-app'; + +describe('Project mutations revalidate in-flight authority under the installation lock', () => { + let ctx: TestContext; + let service: ProjectsService; + beforeAll(async () => { + ctx = await createTestApp(); + service = ctx.app.get(ProjectsService); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture() { + const admin = await seedEmployee(ctx.prisma, { + personalNo: 'PROJECT-ADMIN', + firstName: 'Project', + lastName: 'Admin', + email: 'project-boundary-admin@test.local', + role: 'HRAdmin', + }); + const worker = await seedEmployee(ctx.prisma, { + personalNo: 'PROJECT-WORKER', + firstName: 'Project', + lastName: 'Worker', + email: 'project-boundary-worker@test.local', + }); + const project = await seedProject(ctx.prisma, { + code: 'BOUNDARY', + assigneeIds: [worker.id], + serviceOrders: [{ orderNo: 'A1', title: 'Original' }], + }); + const actor: JwtUser = { + id: admin.id, + email: admin.email, + role: admin.role, + authVersion: 0, + }; + const actions: Array<() => Promise> = [ + () => service.create({ code: 'NEW', name: 'Unauthorized' }, actor), + () => + service.update( + project.id, + { code: 'BOUNDARY', name: 'Unauthorized' }, + actor, + ), + () => service.remove(project.id, actor), + () => + service.createServiceOrder( + project.id, + { orderNo: 'NEW', title: 'Unauthorized' }, + actor, + ), + () => + service.updateServiceOrder( + project.id, + project.serviceOrders[0].id, + { orderNo: 'A1', title: 'Unauthorized' }, + actor, + ), + () => + service.removeServiceOrder( + project.id, + project.serviceOrders[0].id, + actor, + ), + () => service.assign(project.id, admin.id, actor), + () => service.unassign(project.id, worker.id, actor), + ]; + return { admin, worker, project, actor, actions }; + } + + it('rejects every mutation after the captured administrator role is revoked', async () => { + const { admin, actions, project } = await fixture(); + await ctx.prisma.employee.update({ + where: { id: admin.id }, + data: { role: 'Employee' }, + }); + for (const action of actions) + await expect(action()).rejects.toMatchObject({ status: 403 }); + expect(await ctx.prisma.project.count()).toBe(1); + expect(await ctx.prisma.serviceOrder.count()).toBe(1); + expect( + ( + await ctx.prisma.project.findUniqueOrThrow({ + where: { id: project.id }, + }) + ).name, + ).toBe('BOUNDARY'); + }); + + it('rejects every mutation after a captured access-token version is invalidated', async () => { + const { admin, actions } = await fixture(); + await ctx.prisma.employee.update({ + where: { id: admin.id }, + data: { authVersion: { increment: 1 } }, + }); + for (const action of actions) + await expect(action()).rejects.toMatchObject({ status: 403 }); + }); + + it('also rejects customer writes from an invalidated in-flight owner session', async () => { + const { admin, actor } = await fixture(); + const customers = ctx.app.get(CustomersService); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: admin.id }, + update: { mode: 'Solo', ownerEmployeeId: admin.id }, + }); + const customer = await ctx.prisma.customer.create({ + data: { name: 'Original' }, + }); + await ctx.prisma.employee.update({ + where: { id: admin.id }, + data: { authVersion: { increment: 1 } }, + }); + await expect( + customers.create(actor, { name: 'Unauthorized' }), + ).rejects.toMatchObject({ status: 403 }); + await expect( + customers.update(actor, customer.id, { name: 'Unauthorized' }), + ).rejects.toMatchObject({ status: 403 }); + await expect(customers.remove(actor, customer.id)).rejects.toMatchObject({ + status: 403, + }); + expect( + ( + await ctx.prisma.customer.findUniqueOrThrow({ + where: { id: customer.id }, + }) + ).name, + ).toBe('Original'); + }); + + it('cannot finish a request with old Team rights after a concurrent deactivation and Solo switch', async () => { + const { admin, worker, project, actions } = await fixture(); + let pending: Promise | undefined; + await ctx.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + // This represents an HTTP request that already passed its role guard, + // but must now wait for the mode-changing transaction to finish. + pending = actions[1]().catch((error: { status: number }) => ({ + status: error.status, + })); + await tx.employee.update({ + where: { id: admin.id }, + data: { isActive: false }, + }); + await tx.employee.update({ + where: { id: worker.id }, + data: { role: 'HRAdmin' }, + }); + await tx.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: worker.id }, + update: { mode: 'Solo', ownerEmployeeId: worker.id }, + }); + }); + expect(await pending).toEqual({ status: 403 }); + expect( + ( + await ctx.prisma.project.findUniqueOrThrow({ + where: { id: project.id }, + }) + ).name, + ).toBe('BOUNDARY'); + }); +}); diff --git a/apps/api-e2e/src/api/projects.e2e.spec.ts b/apps/api-e2e/src/api/projects.e2e.spec.ts index 7671944..1b9abec 100644 --- a/apps/api-e2e/src/api/projects.e2e.spec.ts +++ b/apps/api-e2e/src/api/projects.e2e.spec.ts @@ -46,7 +46,10 @@ describe('Projects — CRUD, service orders, assignment matrix', () => { const { manager, worker } = await fixture(); await ctx.http.get('/api/projects').expect(200); - await ctx.http.post('/api/projects').send({ code: 'P-1', name: 'One' }).expect(401); + await ctx.http + .post('/api/projects') + .send({ code: 'P-1', name: 'One' }) + .expect(401); const workerToken = await login(ctx.http, worker.email); await ctx.http @@ -95,7 +98,9 @@ describe('Projects — CRUD, service orders, assignment matrix', () => { const activeOnly = await ctx.http.get('/api/projects').expect(200); expect(activeOnly.body.length).toBe(0); - const all = await ctx.http.get('/api/projects?includeInactive=true').expect(200); + const all = await ctx.http + .get('/api/projects?includeInactive=true') + .expect(200); expect(all.body.length).toBe(1); }); @@ -184,7 +189,9 @@ describe('Projects — CRUD, service orders, assignment matrix', () => { .get('/api/projects/assignments') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(matrix.body).toEqual([{ employeeId: worker.id, projectId: project.id }]); + expect(matrix.body).toEqual([ + { employeeId: worker.id, projectId: project.id }, + ]); // Unassigning is idempotent too. await ctx.http @@ -237,8 +244,16 @@ describe('Projects — CRUD, service orders, assignment matrix', () => { id: active.id, code: 'P-ACTIVE', name: 'P-ACTIVE', + customerId: null, + customerName: null, + defaultBillable: false, serviceOrders: [ - { id: active.serviceOrders[0].id, orderNo: 'SA-1', title: 'Aktiv' }, + { + id: active.serviceOrders[0].id, + orderNo: 'SA-1', + title: 'Aktiv', + defaultBillable: null, + }, ], }, ]); @@ -332,12 +347,18 @@ describe('Projects — CRUD, service orders, assignment matrix', () => { }); const order = project.serviceOrders[0]; const now = new Date(); - const mk = (hoursAgo: number, lengthHours: number, extra: Record = {}) => + const mk = ( + hoursAgo: number, + lengthHours: number, + extra: Record = {}, + ) => ctx.prisma.timeEntry.create({ data: { employeeId: worker.id, clockIn: new Date(now.getTime() - hoursAgo * 60 * 60 * 1000), - clockOut: new Date(now.getTime() - (hoursAgo - lengthHours) * 60 * 60 * 1000), + clockOut: new Date( + now.getTime() - (hoursAgo - lengthHours) * 60 * 60 * 1000, + ), status: 'Pending', projectId: project.id, ...extra, @@ -347,7 +368,12 @@ describe('Projects — CRUD, service orders, assignment matrix', () => { await mk(7, 2); // 120 min project-level await mk(4, 1, { status: 'Rejected' }); // ignored await ctx.prisma.timeEntry.create({ - data: { employeeId: worker.id, clockIn: now, clockOut: null, projectId: project.id }, + data: { + employeeId: worker.id, + clockIn: now, + clockOut: null, + projectId: project.id, + }, }); // open → ignored const res = await ctx.http.get(`/api/projects/${project.id}`).expect(200); diff --git a/apps/api-e2e/src/api/realtime-session-boundary.e2e.spec.ts b/apps/api-e2e/src/api/realtime-session-boundary.e2e.spec.ts new file mode 100644 index 0000000..9a3e3aa --- /dev/null +++ b/apps/api-e2e/src/api/realtime-session-boundary.e2e.spec.ts @@ -0,0 +1,183 @@ +import { JwtService } from '@nestjs/jwt'; +import type { Socket } from 'socket.io'; +import { EventsGateway } from '../../../api/src/app/events/events.gateway'; +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +describe('Realtime session boundary against persisted identity and installation state', () => { + let ctx: TestContext; + let gateway: EventsGateway; + let jwt: JwtService; + const clients: Socket[] = []; + + beforeAll(async () => { + ctx = await createTestApp(); + gateway = ctx.app.get(EventsGateway); + jwt = ctx.app.get(JwtService); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + afterEach(() => { + clients.splice(0).forEach((client) => { + gateway.handleDisconnect(client); + }); + jest.restoreAllMocks(); + }); + + // Exercise the gateway's real async handshake and fanout with real JWT/DB + // state. The transport is a test socket so security checks need no sleep or + // races waiting for socket.io's connect acknowledgement. + function socket(token?: string, header = false): Socket { + const client = { + id: `test-socket-${clients.length}`, + connected: true, + handshake: { + auth: header ? {} : { token }, + headers: header ? { authorization: `Bearer ${token}` } : {}, + }, + data: {}, + emit: jest.fn(), + disconnect: jest.fn(() => { + client.connected = false; + gateway.handleDisconnect(client as unknown as Socket); + return client; + }), + } as unknown as Socket; + clients.push(client); + return client; + } + + async function fixture() { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'OWNER', + firstName: 'Solo', + lastName: 'Owner', + email: 'realtime-owner@test.local', + role: 'HRAdmin', + }); + const other = await seedEmployee(ctx.prisma, { + personalNo: 'OTHER', + firstName: 'Other', + lastName: 'User', + email: 'realtime-other@test.local', + }); + return { owner, other }; + } + + it('rejects refresh, absent, expired and version-mismatched tokens at handshake', async () => { + const { owner } = await fixture(); + const payload = { + sub: owner.id, + email: owner.email, + role: owner.role, + ver: 0, + }; + const tokens = [ + undefined, + jwt.sign({ ...payload, typ: 'refresh' }), + jwt.sign({ ...payload, typ: 'access', ver: 1 }), + jwt.sign({ ...payload, typ: 'access' }, { expiresIn: -1 }), + ]; + for (const token of tokens) { + const client = socket(token); + await gateway.handleConnection(client); + expect(client.disconnect).toHaveBeenCalledWith(true); + expect(client.data).not.toHaveProperty('user'); + } + }); + + it('uses fresh roles and prevents deactivated sessions from receiving the next event', async () => { + const { owner } = await fixture(); + const token = await login(ctx.http, owner.email); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { role: 'Manager' }, + }); + const client = socket(token, true); + await gateway.handleConnection(client); + expect(client.data.user.role).toBe('Manager'); + await gateway.broadcast('project:changed', { projectId: 'example' }); + expect(client.emit).toHaveBeenCalledWith('project:changed', { + projectId: 'example', + }); + jest.mocked(client.emit).mockClear(); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { isActive: false }, + }); + await gateway.broadcast('project:changed', { projectId: 'private' }); + expect(client.disconnect).toHaveBeenCalledWith(true); + expect(client.emit).not.toHaveBeenCalled(); + }); + + it('rechecks Solo ownership for old Team sockets and new handshakes', async () => { + const { owner, other } = await fixture(); + const ownerToken = await login(ctx.http, owner.email); + const otherToken = await login(ctx.http, other.email); + const ownerClient = socket(ownerToken); + const otherClient = socket(otherToken); + await gateway.handleConnection(ownerClient); + await gateway.handleConnection(otherClient); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: owner.id }, + update: { mode: 'Solo', ownerEmployeeId: owner.id }, + }); + await gateway.broadcast('time-entry:created', { employeeId: owner.id }); + expect(ownerClient.emit).toHaveBeenCalledWith('time-entry:created', { + employeeId: owner.id, + }); + expect(otherClient.emit).not.toHaveBeenCalled(); + expect(otherClient.disconnect).toHaveBeenCalledWith(true); + jest.mocked(ownerClient.emit).mockClear(); + await gateway.broadcast('time-entry:created', { employeeId: other.id }); + expect(ownerClient.emit).not.toHaveBeenCalled(); + const denied = socket(otherToken); + await gateway.handleConnection(denied); + expect(denied.disconnect).toHaveBeenCalledWith(true); + }); + + it('disconnects an invalidated password session and accepts the newly issued access token', async () => { + const { owner } = await fixture(); + const oldToken = await login(ctx.http, owner.email); + const oldClient = socket(oldToken); + await gateway.handleConnection(oldClient); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { authVersion: { increment: 1 } }, + }); + const newClient = socket(await login(ctx.http, owner.email)); + await gateway.handleConnection(newClient); + await gateway.broadcast('project:changed', { + projectId: 'after-password-change', + }); + expect(oldClient.disconnect).toHaveBeenCalledWith(true); + expect(oldClient.emit).not.toHaveBeenCalled(); + expect(newClient.emit).toHaveBeenCalledWith('project:changed', { + projectId: 'after-password-change', + }); + const denied = socket(oldToken); + await gateway.handleConnection(denied); + expect(denied.disconnect).toHaveBeenCalledWith(true); + }); + + it('fails closed if authorization cannot be revalidated', async () => { + const { owner } = await fixture(); + const client = socket(await login(ctx.http, owner.email)); + await gateway.handleConnection(client); + jest + .spyOn(ctx.prisma.employee, 'findMany') + .mockRejectedValueOnce(new Error('Database unavailable')); + await gateway.broadcast('project:changed', { projectId: 'private' }); + expect(client.disconnect).toHaveBeenCalledWith(true); + expect(client.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api-e2e/src/api/solo-customers-reports.e2e.spec.ts b/apps/api-e2e/src/api/solo-customers-reports.e2e.spec.ts new file mode 100644 index 0000000..4a2ffb3 --- /dev/null +++ b/apps/api-e2e/src/api/solo-customers-reports.e2e.spec.ts @@ -0,0 +1,442 @@ +import { randomUUID } from 'node:crypto'; +import { + createTestApp, + login, + seedEmployee, + seedProject, + type TestContext, +} from '../support/test-app'; + +describe('Solo customers, project ownership and customer statements', () => { + let ctx: TestContext; + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture() { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'SOLO', + firstName: 'Solo', + lastName: 'Owner', + email: 'solo-owner@test.local', + role: 'HRAdmin', + }); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: owner.id, setupCompleted: true }, + update: { mode: 'Solo', ownerEmployeeId: owner.id, setupCompleted: true }, + }); + const token = await login(ctx.http, owner.email); + return { owner, authorization: `Bearer ${token}` }; + } + + async function customer(authorization: string, name = 'Example customer') { + return ( + await ctx.http + .post('/api/customers') + .set('Authorization', authorization) + .send({ name }) + .expect(201) + ).body as { id: string; name: string }; + } + + it('requires Solo ownership and keeps legacy Team project reads public', async () => { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'TEAM', + firstName: 'Team', + lastName: 'Admin', + email: 'team-admin@test.local', + role: 'HRAdmin', + }); + const token = await login(ctx.http, owner.email); + await ctx.http.get('/api/projects').expect(200); + await ctx.http + .get('/api/customers') + .set('Authorization', `Bearer ${token}`) + .expect(403); + await ctx.http + .get('/api/reports/solo?from=2026-09-01&to=2026-09-30') + .set('Authorization', `Bearer ${token}`) + .expect(403); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: owner.id }, + update: { mode: 'Solo', ownerEmployeeId: owner.id }, + }); + await ctx.http.get('/api/projects').expect(401); + await ctx.http.get('/api/customers').expect(401); + await ctx.http + .get('/api/projects') + .set('Authorization', `Bearer ${token}`) + .expect(200); + }); + + it('creates minimal customers and assigns new projects to the owner atomically', async () => { + const { owner, authorization } = await fixture(); + const client = await customer(authorization); + const response = await ctx.http + .post('/api/projects') + .set('Authorization', authorization) + .send({ + code: 'OWN', + name: 'Development', + customerId: client.id, + defaultBillable: true, + }) + .expect(201); + expect(response.body).toMatchObject({ + assignedEmployeeCount: 1, + customerId: client.id, + customerName: client.name, + defaultBillable: true, + bookedNetMinutes: 0, + }); + expect( + await ctx.prisma.projectAssignment.count({ + where: { projectId: response.body.id, employeeId: owner.id }, + }), + ).toBe(1); + const bookable = await ctx.http + .get(`/api/projects/bookable?employeeId=${owner.id}`) + .set('Authorization', authorization) + .expect(200); + expect(bookable.body[0]).toMatchObject({ + customerId: client.id, + defaultBillable: true, + }); + const order = await ctx.http + .post(`/api/projects/${response.body.id}/service-orders`) + .set('Authorization', authorization) + .send({ orderNo: 'A1', title: 'Implementation', defaultBillable: false }) + .expect(201); + expect(order.body.defaultBillable).toBe(false); + const reset = await ctx.http + .put(`/api/projects/${response.body.id}/service-orders/${order.body.id}`) + .set('Authorization', authorization) + .send({ orderNo: 'A1', title: 'Implementation', defaultBillable: null }) + .expect(200); + expect(reset.body.defaultBillable).toBeNull(); + await ctx.http + .delete(`/api/projects/${response.body.id}/assignments/${owner.id}`) + .set('Authorization', authorization) + .expect(409); + }); + + it('validates names and unique references, and deletes unused customers only', async () => { + const { authorization } = await fixture(); + await ctx.http + .post('/api/customers') + .set('Authorization', authorization) + .send({ name: ' ' }) + .expect(400); + const created = await ctx.http + .post('/api/customers') + .set('Authorization', authorization) + .send({ name: ' Client ', code: ' C-1 ', note: 'private' }) + .expect(201); + expect(created.body).toMatchObject({ + name: 'Client', + code: 'C-1', + note: 'private', + projectCount: 0, + }); + await ctx.http + .post('/api/customers') + .set('Authorization', authorization) + .send({ name: 'Other', code: 'C-1' }) + .expect(409); + await ctx.http + .delete(`/api/customers/${created.body.id}`) + .set('Authorization', authorization) + .expect(204); + await ctx.http + .get(`/api/customers/${created.body.id}`) + .set('Authorization', authorization) + .expect(404); + }); + + it('preserves booked customer references, blocks archive of running work and hides archived booking targets', async () => { + const { owner, authorization } = await fixture(); + const first = await customer(authorization, 'First'); + const second = await customer(authorization, 'Second'); + const project = await ctx.prisma.project.create({ + data: { + code: 'P', + name: 'Project', + customerId: first.id, + assignments: { create: { employeeId: owner.id } }, + }, + }); + const order = await ctx.prisma.serviceOrder.create({ + data: { projectId: project.id, orderNo: 'A', title: 'Order' }, + }); + const entry = await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + projectId: project.id, + serviceOrderId: order.id, + clockIn: new Date('2026-09-08T08:00:00Z'), + }, + }); + await ctx.http + .put(`/api/projects/${project.id}`) + .set('Authorization', authorization) + .send({ code: 'P', name: 'Project', customerId: second.id }) + .expect(409); + await ctx.http + .delete(`/api/customers/${first.id}`) + .set('Authorization', authorization) + .expect(409); + await ctx.http + .put(`/api/customers/${first.id}`) + .set('Authorization', authorization) + .send({ name: 'First', isActive: false }) + .expect(409); + await ctx.http + .put(`/api/projects/${project.id}`) + .set('Authorization', authorization) + .send({ code: 'P', name: 'Project', isActive: false }) + .expect(409); + await ctx.http + .put(`/api/projects/${project.id}/service-orders/${order.id}`) + .set('Authorization', authorization) + .send({ orderNo: 'A', title: 'Order', isActive: false }) + .expect(409); + await ctx.prisma.timeEntry.update({ + where: { id: entry.id }, + data: { clockOut: new Date('2026-09-08T09:00:00Z'), status: 'Approved' }, + }); + await ctx.http + .put(`/api/customers/${first.id}`) + .set('Authorization', authorization) + .send({ name: 'First', isActive: false }) + .expect(200); + const bookable = await ctx.http + .get(`/api/projects/bookable?employeeId=${owner.id}`) + .set('Authorization', authorization) + .expect(200); + expect(bookable.body).toHaveLength(0); + await ctx.http + .post('/api/projects') + .set('Authorization', authorization) + .send({ code: 'NEW', name: 'New', customerId: first.id }) + .expect(400); + const report = await ctx.http + .get( + `/api/reports/solo?from=2026-09-08&to=2026-09-08&customerId=${first.id}`, + ) + .set('Authorization', authorization) + .expect(200); + expect(report.body.rows).toHaveLength(1); + }); + + it('allocates one capture-group break across project filters, excludes invalid and foreign entries, and matches net progress', async () => { + const { owner, authorization } = await fixture(); + const foreign = await seedEmployee(ctx.prisma, { + personalNo: 'OLD', + firstName: 'Historic', + lastName: 'Employee', + email: 'historic@test.local', + }); + await ctx.prisma.employee.update({ + where: { id: foreign.id }, + data: { isActive: false }, + }); + const projectA = await seedProject(ctx.prisma, { code: 'A' }); + const projectB = await seedProject(ctx.prisma, { code: 'B' }); + const group = randomUUID(); + const common = { + employeeId: owner.id, + status: 'Approved' as const, + captureGroupId: group, + breakRules: [{ afterMinutes: 360, breakMinutes: 30 }], + }; + await ctx.prisma.timeEntry.createMany({ + data: [ + { + ...common, + projectId: projectA.id, + clockIn: new Date('2026-09-07T06:00:00Z'), + clockOut: new Date('2026-09-07T11:00:00Z'), + billable: true, + }, + { + ...common, + projectId: projectB.id, + clockIn: new Date('2026-09-07T11:00:00Z'), + clockOut: new Date('2026-09-07T13:00:00Z'), + billable: false, + }, + { + ...common, + captureGroupId: randomUUID(), + projectId: projectA.id, + clockIn: new Date('2026-09-07T14:00:00Z'), + clockOut: new Date('2026-09-07T15:00:00Z'), + voidedAt: new Date(), + }, + { + ...common, + captureGroupId: randomUUID(), + projectId: projectA.id, + clockIn: new Date('2026-09-07T15:00:00Z'), + clockOut: new Date('2026-09-07T16:00:00Z'), + status: 'Rejected', + }, + { + employeeId: foreign.id, + projectId: projectA.id, + clockIn: new Date('2026-09-07T06:00:00Z'), + clockOut: new Date('2026-09-07T16:00:00Z'), + status: 'Approved', + }, + { + employeeId: owner.id, + clockIn: new Date('2026-09-07T17:00:00Z'), + clockOut: null, + }, + ], + }); + const all = await ctx.http + .get('/api/reports/solo?from=2026-09-07&to=2026-09-07') + .set('Authorization', authorization) + .expect(200); + expect(all.body.rows).toHaveLength(2); + expect(all.body.openTimerCount).toBe(1); + expect(all.body.totals.grossMinutes).toBe(420); + expect(all.body.totals.breakMinutes).toBe(30); + expect(all.body.totals.netMinutes).toBe(390); + const onlyA = await ctx.http + .get( + `/api/reports/solo?from=2026-09-07&to=2026-09-07&projectId=${projectA.id}&billable=true`, + ) + .set('Authorization', authorization) + .expect(200); + expect(onlyA.body.totals.breakMinutes).toBeCloseTo((30 * 5) / 7, 10); + expect(onlyA.body.totals.billableNetMinutes).toBeCloseTo((390 * 5) / 7, 10); + const progress = await ctx.http + .get(`/api/projects/${projectA.id}`) + .set('Authorization', authorization) + .expect(200); + expect(progress.body.bookedNetMinutes).toBeCloseTo( + onlyA.body.totals.netMinutes, + 10, + ); + const unmatched = await ctx.http + .get(`/api/reports/solo?from=2026-09-07&to=2026-09-07&unassigned=true`) + .set('Authorization', authorization) + .expect(200); + expect(unmatched.body.rows).toHaveLength(0); + expect(unmatched.body.openTimerCount).toBe(1); + }); + + it('clips local midnight and preserves exact UTC duration through DST and sub-minute fragments', async () => { + const { owner, authorization } = await fixture(); + await ctx.prisma.timeEntry.createMany({ + data: [ + { + employeeId: owner.id, + status: 'Approved', + captureGroupId: randomUUID(), + breakRules: [], + clockIn: new Date('2026-03-28T22:30:00Z'), + clockOut: new Date('2026-03-29T02:30:00Z'), + }, + { + employeeId: owner.id, + status: 'Approved', + captureGroupId: randomUUID(), + breakRules: [], + clockIn: new Date('2026-10-25T00:30:00Z'), + clockOut: new Date('2026-10-25T02:30:00Z'), + }, + { + employeeId: owner.id, + status: 'Approved', + captureGroupId: randomUUID(), + breakRules: [], + clockIn: new Date('2026-09-07T21:59:45Z'), + clockOut: new Date('2026-09-07T22:00:15Z'), + }, + ], + }); + const spring = await ctx.http + .get('/api/reports/solo?from=2026-03-29&to=2026-03-29') + .set('Authorization', authorization) + .expect(200); + expect(spring.body.timeZone).toBe('Europe/Berlin'); + expect(spring.body.rows[0].clockIn).toBe('2026-03-28T23:00:00.000Z'); + expect(spring.body.totals.netMinutes).toBe(210); + const autumn = await ctx.http + .get('/api/reports/solo?from=2026-10-25&to=2026-10-25') + .set('Authorization', authorization) + .expect(200); + expect(autumn.body.totals.netMinutes).toBe(120); + const fragment = await ctx.http + .get('/api/reports/solo?from=2026-09-07&to=2026-09-08') + .set('Authorization', authorization) + .expect(200); + expect( + fragment.body.rows.map((row: { netMinutes: number }) => row.netMinutes), + ).toEqual([0.25, 0.25]); + expect(fragment.body.totals.netMinutes).toBe(0.5); + }); + + it('exports customer-safe CSV with timezone, formula escaping and exact minutes', async () => { + const { owner, authorization } = await fixture(); + const client = await customer(authorization, '=HYPERLINK("unsafe")'); + const project = await ctx.prisma.project.create({ + data: { code: 'CSV', name: 'CSV Project', customerId: client.id }, + }); + await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + projectId: project.id, + captureGroupId: randomUUID(), + clockIn: new Date('2026-09-07T08:00:00Z'), + clockOut: new Date('2026-09-07T08:00:30Z'), + status: 'Approved', + breakRules: [], + activity: '@SUM(1,2)\nquoted "text"', + note: 'PRIVATE-NOTE-SECRET', + terminalLocationLabel: 'PRIVATE-LOCATION-SECRET', + billable: true, + }, + }); + const csv = await ctx.http + .get('/api/reports/solo.csv?from=2026-09-07&to=2026-09-07') + .set('Authorization', authorization) + .expect(200); + expect(csv.headers['content-type']).toContain('text/csv'); + expect(csv.headers['content-disposition']).toContain( + 'openclockwork-2026-09-07-2026-09-07.csv', + ); + expect(csv.text).toContain('Europe/Berlin'); + expect(csv.text).toContain('"\'=HYPERLINK(""unsafe"")"'); + expect(csv.text).toContain('"\'@SUM(1,2)\nquoted ""text"""'); + expect(csv.text).toContain('"0.5"'); + expect(csv.text).not.toContain('PRIVATE-'); + }); + + it('rejects invalid dates, incompatible filters and out-of-range statements', async () => { + const { authorization } = await fixture(); + for (const query of [ + 'from=2026-02-31&to=2026-03-01', + 'from=2026-09-08&to=2026-09-07', + 'from=2025-01-01&to=2026-12-31', + 'from=2026-09-07&to=2026-09-07&billable=yes', + `from=2026-09-07&to=2026-09-07&unassigned=true&projectId=${randomUUID()}`, + ]) { + await ctx.http + .get(`/api/reports/solo?${query}`) + .set('Authorization', authorization) + .expect(400); + } + }); +}); diff --git a/apps/api-e2e/src/api/solo-installation.e2e.spec.ts b/apps/api-e2e/src/api/solo-installation.e2e.spec.ts new file mode 100644 index 0000000..5dd5437 --- /dev/null +++ b/apps/api-e2e/src/api/solo-installation.e2e.spec.ts @@ -0,0 +1,600 @@ +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +const settings = { + revision: 0, + effectiveFrom: '', + targetEnabled: false, + weeklyTargetMinutes: null, + workingDays: 31, + leaveEnabled: false, + annualLeaveDays: 0, + holidayCalendar: 'NONE', + holidayDates: [], + breakRules: [], + coreTimeHintsEnabled: false, + dailyBlockEnabled: false, + gpsEnabled: false, +}; +function localDay(offset = 0) { + const date = new Date(); + date.setDate(date.getDate() + offset); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +describe('Solo installation, personal policies and transition boundaries', () => { + let ctx: TestContext; + let ownerId: string; + let token: string; + const auth = () => ({ Authorization: `Bearer ${token}` }); + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'OWNER', + firstName: 'Solo', + lastName: 'Example', + email: 'owner@solo.test', + role: 'HRAdmin', + }); + ownerId = owner.id; + token = await login(ctx.http, owner.email); + }); + async function enableSolo() { + return ctx.http + .post('/api/installation/mode') + .set(auth()) + .send({ mode: 'Solo', revision: 0 }) + .expect(201); + } + + it('keeps upgraded installations in Team until an explicit owner transition', async () => { + const before = await ctx.http + .get('/api/installation') + .set(auth()) + .expect(200); + expect(before.body).toMatchObject({ mode: 'Team', ownerEmployeeId: null }); + const solo = await enableSolo(); + expect(solo.body).toMatchObject({ + mode: 'Solo', + ownerEmployeeId: ownerId, + setupCompleted: false, + capabilities: { solo: true, targets: false, leave: false, gps: false }, + }); + await ctx.http + .post('/api/installation/complete-setup') + .set(auth()) + .expect(201); + expect( + (await ctx.http.get('/api/installation').set(auth())).body.setupCompleted, + ).toBe(true); + }); + + it('blocks conversion while other people or unresolved workflows exist, without mutation', async () => { + await seedEmployee(ctx.prisma, { + personalNo: 'OTHER', + firstName: 'Team', + lastName: 'Member', + email: 'member@solo.test', + }); + const preview = await ctx.http + .post('/api/installation/mode-preview') + .set(auth()) + .send({ mode: 'Solo' }) + .expect(201); + expect(preview.body).toMatchObject({ + allowed: false, + blockers: ['OTHER_ACTIVE_EMPLOYEES'], + }); + await ctx.http + .post('/api/installation/mode') + .set(auth()) + .send({ mode: 'Solo', revision: 0 }) + .expect(409); + expect( + (await ctx.http.get('/api/installation').set(auth())).body.mode, + ).toBe('Team'); + }); + + it('returns a conflict for a concurrent serializable transition and commits only one mode event', async () => { + let releaseLock!: () => void; + let lockAcquired!: () => void; + const acquired = new Promise((resolve) => { + lockAcquired = resolve; + }); + const release = new Promise((resolve) => { + releaseLock = resolve; + }); + const blocker = ctx.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; + lockAcquired(); + await release; + }); + await acquired; + const requests = [1, 2].map(() => + ctx.http + .post('/api/installation/mode') + .set(auth()) + .send({ mode: 'Solo', revision: 0 }) + .then((response) => response), + ); + let waiting = 0; + try { + for (let attempt = 0; attempt < 100 && waiting < 2; attempt++) { + const locks = await ctx.prisma.$queryRaw< + Array<{ count: number }> + >`SELECT COUNT(*)::int AS count FROM pg_locks WHERE locktype = 'advisory' AND objid = 7261500 AND NOT granted AND database = (SELECT oid FROM pg_database WHERE datname = current_database())`; + waiting = locks[0]?.count ?? 0; + if (waiting < 2) + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } finally { + releaseLock(); + await blocker; + } + const responses = await Promise.all(requests); + expect(waiting).toBe(2); + expect(responses.map((response) => response.status).sort()).toEqual([ + 201, 409, + ]); + expect( + responses.find((response) => response.status === 409)?.body.message, + ).toMatch(/concurrently/); + expect( + await ctx.prisma.installationSettings.findUnique({ where: { id: 1 } }), + ).toMatchObject({ mode: 'Solo', ownerEmployeeId: ownerId, revision: 1 }); + expect( + await ctx.prisma.installationEvent.count({ + where: { action: 'ModeChanged' }, + }), + ).toBe(1); + expect( + await ctx.prisma.soloPolicy.count({ where: { employeeId: ownerId } }), + ).toBe(1); + }); + + it('enforces a non-null positive weekly target in the database only when enabled', async () => { + for (const weeklyTargetMinutes of [null, 0, -1, 10081]) { + await expect( + ctx.prisma.soloPolicy.create({ + data: { + employeeId: ownerId, + effectiveFrom: new Date('2026-01-01'), + targetEnabled: true, + weeklyTargetMinutes, + }, + }), + ).rejects.toThrow(); + } + expect(await ctx.prisma.soloPolicy.count()).toBe(0); + await ctx.prisma.soloPolicy.create({ + data: { + employeeId: ownerId, + effectiveFrom: new Date('2026-01-01'), + targetEnabled: false, + weeklyTargetMinutes: null, + }, + }); + await ctx.prisma.soloPolicy.create({ + data: { + employeeId: ownerId, + effectiveFrom: new Date('2026-02-01'), + targetEnabled: true, + weeklyTargetMinutes: 1200, + }, + }); + expect(await ctx.prisma.soloPolicy.count()).toBe(2); + }); + + it('switches live mode immediately but starts accounting tomorrow after completed overnight work', async () => { + await ctx.prisma.timeEntry.create({ + data: { + employeeId: ownerId, + clockIn: new Date(`${localDay(-1)}T23:30:00`), + clockOut: new Date(`${localDay()}T00:30:00`), + status: 'Approved', + }, + }); + const response = await enableSolo(); + expect(response.body.mode).toBe('Solo'); + const event = await ctx.prisma.installationEvent.findFirstOrThrow({ + where: { action: 'ModeChanged' }, + }); + expect(event.after).toMatchObject({ + mode: 'Solo', + accountingEffectiveFrom: localDay(1), + }); + const policy = await ctx.prisma.soloPolicy.findFirstOrThrow({ + where: { employeeId: ownerId }, + }); + expect(policy.effectiveFrom.toISOString().slice(0, 10)).toBe(localDay(1)); + }); + + it('defers accounting for existing personal or approved legacy free days', async () => { + await ctx.prisma.request.create({ + data: { + employeeId: ownerId, + type: 'Vacation', + from: new Date(localDay()), + to: new Date(localDay()), + workflowState: 'Approved', + }, + }); + await enableSolo(); + expect( + ( + await ctx.prisma.installationEvent.findFirstOrThrow({ + where: { action: 'ModeChanged' }, + }) + ).after, + ).toMatchObject({ accountingEffectiveFrom: localDay(1) }); + }); + + it("does not postpone accounting for rejected, voided or another employee's work", async () => { + const other = await seedEmployee(ctx.prisma, { + personalNo: 'FORMER', + firstName: 'Former', + lastName: 'Employee', + email: 'former-accounting@solo.test', + }); + await ctx.prisma.employee.update({ + where: { id: other.id }, + data: { isActive: false }, + }); + const clockIn = new Date(`${localDay()}T00:00:00`), + clockOut = new Date(`${localDay()}T00:01:00`); + await ctx.prisma.timeEntry.createMany({ + data: [ + { employeeId: other.id, clockIn, clockOut, status: 'Approved' }, + { employeeId: ownerId, clockIn, clockOut, status: 'Rejected' }, + { + employeeId: ownerId, + clockIn, + clockOut, + status: 'Approved', + voidedAt: new Date(), + }, + ], + }); + await enableSolo(); + expect( + ( + await ctx.prisma.installationEvent.findFirstOrThrow({ + where: { action: 'ModeChanged' }, + }) + ).after, + ).toMatchObject({ accountingEffectiveFrom: localDay() }); + }); + + it('does not give free tracking an artificial overtime or holiday balance', async () => { + await enableSolo(); + await ctx.http + .post('/api/timeentries/manual') + .set(auth()) + .send({ + clockIn: `${localDay(-1)}T09:00:00+02:00`, + clockOut: `${localDay(-1)}T10:00:00+02:00`, + }) + .expect(201); + const summary = await ctx.http + .get('/api/installation/summary') + .set(auth()) + .query({ from: localDay(-1), to: localDay(-1) }) + .expect(200); + expect(summary.body).toMatchObject({ + actualMinutes: 60, + targetEnabled: false, + targetMinutes: null, + overtimeMinutes: null, + leaveEnabled: false, + vacationDaysRemaining: null, + }); + }); + + it('validates policy dates and requires revision concurrency control', async () => { + await enableSolo(); + const state = (await ctx.http.get('/api/installation').set(auth())).body; + const payload = { + ...settings, + revision: state.revision, + effectiveFrom: localDay(1), + targetEnabled: true, + weeklyTargetMinutes: 1200, + }; + await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send({ ...payload, effectiveFrom: localDay(-1) }) + .expect(400); + await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send({ ...payload, weeklyTargetMinutes: 0 }) + .expect(400); + await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send({ ...payload, holidayDates: ['2026-02-30'] }) + .expect(400); + const saved = await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send(payload) + .expect(200); + expect(saved.body.futurePolicies).toHaveLength(1); + expect(saved.body.capabilities.targets).toBe(false); + await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send(payload) + .expect(409); + }); + + it('preserves historical targets across effective policy versions', async () => { + await enableSolo(); + // These periods belong to an already-Solo installation, not a Team period + // before today's transition. Model that history explicitly. + await ctx.prisma.installationEvent.updateMany({ + where: { actorId: ownerId, action: 'ModeChanged' }, + data: { + occurredAt: new Date('2026-01-01T00:00:00Z'), + after: { + mode: 'Solo', + ownerEmployeeId: ownerId, + accountingEffectiveFrom: '2026-01-01', + }, + }, + }); + await ctx.prisma.soloPolicy.createMany({ + data: [ + { + employeeId: ownerId, + effectiveFrom: new Date('2026-01-01'), + targetEnabled: true, + weeklyTargetMinutes: 2400, + workingDays: 31, + }, + { + employeeId: ownerId, + effectiveFrom: new Date('2026-02-01'), + targetEnabled: true, + weeklyTargetMinutes: 1200, + workingDays: 31, + }, + ], + }); + const jan = await ctx.http + .get('/api/installation/summary') + .set(auth()) + .query({ from: '2026-01-05', to: '2026-01-09' }) + .expect(200); + const feb = await ctx.http + .get('/api/installation/summary') + .set(auth()) + .query({ from: '2026-02-02', to: '2026-02-06' }) + .expect(200); + expect(jan.body.targetMinutes).toBe(2400); + expect(feb.body.targetMinutes).toBe(1200); + }); + + it('protects today after closed overnight work when changing calendar policies', async () => { + await enableSolo(); + await ctx.prisma.timeEntry.create({ + data: { + employeeId: ownerId, + clockIn: new Date(`${localDay(-1)}T23:30:00`), + clockOut: new Date(`${localDay()}T00:30:00`), + source: 'Manual', + status: 'Approved', + requiresApproval: false, + }, + }); + const state = (await ctx.http.get('/api/installation').set(auth())).body; + await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send({ + ...settings, + revision: state.revision, + effectiveFrom: localDay(), + }) + .expect(409); + expect( + (await ctx.http.get('/api/installation').set(auth())).body.revision, + ).toBe(state.revision); + }); + + it("protects inherited approved leave when changing today's personal policy", async () => { + await enableSolo(); + await ctx.prisma.request.create({ + data: { + employeeId: ownerId, + type: 'Vacation', + from: new Date(localDay()), + to: new Date(localDay()), + workflowState: 'Approved', + }, + }); + const state = (await ctx.http.get('/api/installation').set(auth())).body; + await ctx.http + .patch('/api/installation/settings') + .set(auth()) + .send({ + ...settings, + revision: state.revision, + effectiveFrom: localDay(), + }) + .expect(409); + }); + + it('creates, edits and cancels personal calendar days with audit and no request workflow', async () => { + await enableSolo(); + const created = await ctx.http + .post('/api/installation/days') + .set(auth()) + .send({ + kind: 'Free', + from: '2026-10-05', + to: '2026-10-06', + note: 'Private planning', + }) + .expect(201); + expect(await ctx.prisma.request.count()).toBe(0); + await ctx.http + .post('/api/installation/days') + .set(auth()) + .send({ kind: 'Vacation', from: '2026-10-06', to: '2026-10-07' }) + .expect(409); + const edited = await ctx.http + .patch(`/api/installation/days/${created.body.id}`) + .set(auth()) + .send({ + kind: 'Vacation', + from: '2026-10-05', + to: '2026-10-06', + halfDayStart: true, + revision: 0, + }) + .expect(200); + await ctx.http + .delete(`/api/installation/days/${created.body.id}`) + .set(auth()) + .send({ revision: 0 }) + .expect(409); + await ctx.http + .delete(`/api/installation/days/${created.body.id}`) + .set(auth()) + .send({ revision: edited.body.revision }) + .expect(200); + const audit = await ctx.http + .get(`/api/installation/days/${created.body.id}/audit`) + .set(auth()) + .expect(200); + expect(audit.body.map((e: { action: string }) => e.action)).toEqual([ + 'PersonalDayCreated', + 'PersonalDayChanged', + 'PersonalDayCancelled', + ]); + }); + + it('rejects hidden Team routes and another historical identity while in Solo', async () => { + const other = await seedEmployee(ctx.prisma, { + personalNo: 'OLD', + firstName: 'Former', + lastName: 'Member', + email: 'old@solo.test', + }); + const oldToken = await login(ctx.http, other.email); + await ctx.prisma.employee.update({ + where: { id: other.id }, + data: { isActive: false }, + }); + await enableSolo(); + await ctx.http.get('/api/employees').set(auth()).expect(403); + await ctx.http.get(`/api/accounts/${other.id}`).set(auth()).expect(403); + await ctx.http + .put(`/api/employees/${ownerId}`) + .set(auth()) + .send({ isActive: false }) + .expect(403); + await ctx.http.post('/api/employees').set(auth()).send({}).expect(403); + await ctx.http + .get('/api/installation') + .set('Authorization', `Bearer ${oldToken}`) + .expect(401); + expect( + (await ctx.prisma.employee.findUniqueOrThrow({ where: { id: ownerId } })) + .isActive, + ).toBe(true); + }); + + it('keeps ids, bookings and settings through Solo to Team and back', async () => { + await enableSolo(); + const entry = await ctx.http + .post('/api/timeentries/manual') + .set(auth()) + .send({ + clockIn: '2026-06-01T09:00:00Z', + clockOut: '2026-06-01T10:00:00Z', + }) + .expect(201); + const before = (await ctx.http.get('/api/installation').set(auth())).body; + const team = await ctx.http + .post('/api/installation/mode') + .set(auth()) + .send({ mode: 'Team', revision: before.revision }) + .expect(201); + await ctx.http + .post('/api/installation/mode') + .set(auth()) + .send({ mode: 'Solo', revision: team.body.revision }) + .expect(201); + const stored = await ctx.prisma.timeEntry.findUniqueOrThrow({ + where: { id: entry.body.id }, + }); + expect(stored).toMatchObject({ + employeeId: ownerId, + approvalMode: 'Solo', + status: 'Approved', + }); + expect(await ctx.prisma.employee.count()).toBe(1); + }); + + it('requires open timers to be closed before a mode change', async () => { + await enableSolo(); + await ctx.http + .post('/api/timeentries/clock-in') + .set(auth()) + .send({}) + .expect(201); + const preview = await ctx.http + .post('/api/installation/mode-preview') + .set(auth()) + .send({ mode: 'Team' }) + .expect(201); + expect(preview.body.blockers).toContain('OPEN_TIME_ENTRIES'); + }); + + it('requires the current password and invalidates old access and refresh sessions', async () => { + await enableSolo(); + const session = await ctx.http + .post('/api/auth/login') + .send({ email: 'owner@solo.test', password: 'test1234' }) + .expect(200); + await ctx.http + .post('/api/auth/password') + .set(auth()) + .send({ currentPassword: 'wrong', newPassword: 'new-test-password-150' }) + .expect(401); + await ctx.http + .post('/api/auth/password') + .set(auth()) + .send({ + currentPassword: 'test1234', + newPassword: 'new-test-password-150', + }) + .expect(200); + await ctx.http.get('/api/auth/me').set(auth()).expect(401); + await ctx.http + .post('/api/auth/refresh') + .send({ refreshToken: session.body.refreshToken }) + .expect(401); + const fresh = await ctx.http + .post('/api/auth/login') + .send({ email: 'owner@solo.test', password: 'new-test-password-150' }) + .expect(200); + await ctx.http + .get('/api/auth/me') + .set('Authorization', `Bearer ${fresh.body.accessToken}`) + .expect(200); + }); +}); diff --git a/apps/api-e2e/src/api/solo-owner-recovery.e2e.spec.ts b/apps/api-e2e/src/api/solo-owner-recovery.e2e.spec.ts new file mode 100644 index 0000000..90d4f5d --- /dev/null +++ b/apps/api-e2e/src/api/solo-owner-recovery.e2e.spec.ts @@ -0,0 +1,251 @@ +import { spawn } from 'node:child_process'; +import type { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { + createTestApp, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +interface RecoveryLibrary { + OwnerRecoveryRefusedError: new (message: string) => Error; + parseOwnerRecoveryArguments(args: string[]): string; + resetOwnerPassword( + prisma: PrismaClient, + confirmedEmail: string, + ): Promise<{ + ownerId: string; + email: string; + password: string; + authVersion: number; + }>; +} + +describe('Local Solo owner password recovery', () => { + let ctx: TestContext; + let OwnerRecoveryRefusedError: RecoveryLibrary['OwnerRecoveryRefusedError']; + let parseOwnerRecoveryArguments: RecoveryLibrary['parseOwnerRecoveryArguments']; + let resetOwnerPassword: RecoveryLibrary['resetOwnerPassword']; + beforeAll(async () => { + // The CLI library lives outside this project's compilation root. Load its + // runtime contract without pulling the CLI sources into the E2E TS project. + const recoveryLibraryPath = '../../../../prisma/reset-owner-password-lib'; + ({ + OwnerRecoveryRefusedError, + parseOwnerRecoveryArguments, + resetOwnerPassword, + } = (await import(recoveryLibraryPath)) as RecoveryLibrary); + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture(mode: 'Team' | 'Solo' = 'Solo') { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'OWNER', + firstName: 'Recover', + lastName: 'Owner', + email: 'recovery@example.test', + role: 'HRAdmin', + }); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode, ownerEmployeeId: owner.id }, + update: { mode, ownerEmployeeId: owner.id }, + }); + return owner; + } + + it('retains the owner ID and working data, generates a strong password and invalidates existing sessions', async () => { + const owner = await fixture(); + const beforeLogin = await ctx.http + .post('/api/auth/login') + .send({ email: owner.email, password: 'test1234' }) + .expect(200); + const time = await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date('2025-01-01T08:00:00Z'), + clockOut: new Date('2025-01-01T09:00:00Z'), + status: 'Approved', + }, + }); + const recovered = await resetOwnerPassword(ctx.prisma, owner.email); + expect(recovered.ownerId).toBe(owner.id); + expect(recovered.password).toMatch(/^[A-Za-z0-9_-]{32}$/); + const after = await ctx.prisma.employee.findUniqueOrThrow({ + where: { id: owner.id }, + }); + expect(after.authVersion).toBe(owner.authVersion + 1); + expect(await bcrypt.compare(recovered.password, after.passwordHash)).toBe( + true, + ); + expect(await ctx.prisma.employee.count()).toBe(1); + expect( + await ctx.prisma.timeEntry.findUnique({ where: { id: time.id } }), + ).toEqual(time); + expect(after).toMatchObject({ + email: owner.email, + role: owner.role, + isActive: true, + personalNo: owner.personalNo, + }); + await ctx.http + .get('/api/auth/me') + .set('Authorization', `Bearer ${beforeLogin.body.accessToken}`) + .expect(401); + await ctx.http + .post('/api/auth/refresh') + .send({ refreshToken: beforeLogin.body.refreshToken }) + .expect(401); + await ctx.http + .post('/api/auth/login') + .send({ email: owner.email, password: 'test1234' }) + .expect(401); + await ctx.http + .post('/api/auth/login') + .send({ email: owner.email, password: recovered.password }) + .expect(200); + const event = await ctx.prisma.installationEvent.findFirstOrThrow({ + where: { action: 'OwnerPasswordRecovered' }, + }); + expect(event.actorId).toBeNull(); + expect(JSON.stringify(event)).not.toContain(recovered.password); + expect(JSON.stringify(event)).not.toContain(after.passwordHash); + }); + + it('requires the exact current active owner and never turns Team recovery into privilege escalation', async () => { + const owner = await fixture('Team'); + await expect( + resetOwnerPassword(ctx.prisma, owner.email), + ).rejects.toBeInstanceOf(OwnerRecoveryRefusedError); + await ctx.prisma.installationSettings.update({ + where: { id: 1 }, + data: { mode: 'Solo' }, + }); + for (const email of ['different@example.test', owner.email.toUpperCase()]) + await expect( + resetOwnerPassword(ctx.prisma, email), + ).rejects.toBeInstanceOf(OwnerRecoveryRefusedError); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { isActive: false }, + }); + await expect( + resetOwnerPassword(ctx.prisma, owner.email), + ).rejects.toBeInstanceOf(OwnerRecoveryRefusedError); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { isActive: true, role: 'Employee' }, + }); + await expect( + resetOwnerPassword(ctx.prisma, owner.email), + ).rejects.toBeInstanceOf(OwnerRecoveryRefusedError); + const after = await ctx.prisma.employee.findUniqueOrThrow({ + where: { id: owner.id }, + }); + expect(after.passwordHash).toBe(owner.passwordHash); + expect(after.authVersion).toBe(owner.authVersion); + expect(await ctx.prisma.employee.count()).toBe(1); + expect( + await ctx.prisma.installationEvent.count({ + where: { action: 'OwnerPasswordRecovered' }, + }), + ).toBe(0); + }); + + it('allows only one concurrent recovery of the same credential version to succeed', async () => { + const owner = await fixture(); + const results = await Promise.allSettled([ + resetOwnerPassword(ctx.prisma, owner.email), + resetOwnerPassword(ctx.prisma, owner.email), + ]); + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === 'rejected'), + ).toHaveLength(1); + expect( + (await ctx.prisma.employee.findUniqueOrThrow({ where: { id: owner.id } })) + .authVersion, + ).toBe(owner.authVersion + 1); + expect( + await ctx.prisma.installationEvent.count({ + where: { action: 'OwnerPasswordRecovered' }, + }), + ).toBe(1); + }); + + it('prints the generated password once only after successful CLI recovery', async () => { + const owner = await fixture(); + const result = await runRecovery(['--email', owner.email]); + expect(result.code).toBe(0); + const matches = [...result.stdout.matchAll(/Recovery password: (\S+)/g)]; + expect(matches).toHaveLength(1); + const password = matches[0]?.[1]; + if (!password) throw new Error('Recovery output was missing'); + const after = await ctx.prisma.employee.findUniqueOrThrow({ + where: { id: owner.id }, + }); + expect(await bcrypt.compare(password, after.passwordHash)).toBe(true); + expect(result.stderr).not.toMatch( + /Owner password recovery (failed|refused)/, + ); + expect(result.stderr).not.toContain(password); + const denied = await runRecovery(['--email', 'wrong@example.test']); + expect(denied.code).toBe(1); + expect(denied.stdout).not.toContain('Recovery password:'); + expect(denied.stderr).not.toContain(after.passwordHash); + expect(denied.stderr).not.toContain('postgresql://'); + }); + + it('rejects missing confirmation and arbitrary flags before touching the database', () => { + for (const args of [ + [], + ['--email'], + ['--email', 'not-an-email'], + ['--email', 'owner@example.test', '--password', 'something'], + ['--mode', 'Solo'], + ]) { + expect(() => parseOwnerRecoveryArguments(args)).toThrow( + OwnerRecoveryRefusedError, + ); + } + expect(parseOwnerRecoveryArguments(['--email', 'owner@example.test'])).toBe( + 'owner@example.test', + ); + }); +}); + +function runRecovery( + args: string[], +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--import', 'tsx', 'prisma/reset-owner-password.ts', ...args], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let stdout = '', + stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + }); +} diff --git a/apps/api-e2e/src/api/solo-summary.e2e.spec.ts b/apps/api-e2e/src/api/solo-summary.e2e.spec.ts new file mode 100644 index 0000000..8822e07 --- /dev/null +++ b/apps/api-e2e/src/api/solo-summary.e2e.spec.ts @@ -0,0 +1,518 @@ +import { randomUUID } from 'node:crypto'; +import type { Prisma } from '@prisma/client'; +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +describe('Solo summaries preserve optional rules and calendar history', () => { + let ctx: TestContext; + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture() { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'SUMMARY', + firstName: 'Solo', + lastName: 'Summary', + email: 'solo-summary@test.local', + role: 'HRAdmin', + holidayCalendar: 'NONE', + }); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: owner.id }, + update: { mode: 'Solo', ownerEmployeeId: owner.id }, + }); + const authorization = `Bearer ${await login(ctx.http, owner.email)}`; + const policy = ( + effectiveFrom: string, + values: Partial = {}, + ) => + ctx.prisma.soloPolicy.create({ + data: { + employeeId: owner.id, + effectiveFrom: new Date(effectiveFrom), + targetEnabled: false, + leaveEnabled: false, + holidayCalendar: 'NONE', + workingDays: 31, + ...values, + }, + }); + const entry = ( + from: string, + to: string, + values: Partial = {}, + ) => + ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date(from), + clockOut: new Date(to), + captureGroupId: randomUUID(), + status: 'Approved', + breakRules: [], + ...values, + }, + }); + const day = ( + from: string, + to = from, + values: Partial = {}, + ) => + ctx.prisma.personalDay.create({ + data: { + employeeId: owner.id, + kind: 'Vacation', + from: new Date(from), + to: new Date(to), + ...values, + }, + }); + const summary = async (from: string, to = from) => + ( + await ctx.http + .get(`/api/installation/summary?from=${from}&to=${to}`) + .set('Authorization', authorization) + .expect(200) + ).body; + return { owner, authorization, policy, entry, day, summary }; + } + + it('does not turn work before activation or after deactivation into overtime', async () => { + const { policy, entry, summary } = await fixture(); + await policy('2026-09-01'); + await policy('2026-09-09', { + targetEnabled: true, + weeklyTargetMinutes: 600, + }); + await policy('2026-09-11', { targetEnabled: false }); + await entry('2026-09-07T06:00:00Z', '2026-09-07T16:00:00Z'); + await entry('2026-09-09T06:00:00Z', '2026-09-09T09:00:00Z'); + await entry('2026-09-10T06:00:00Z', '2026-09-10T08:00:00Z'); + await entry('2026-09-11T02:00:00Z', '2026-09-11T17:00:00Z'); + const combined = await summary('2026-09-07', '2026-09-11'); + expect(combined).toMatchObject({ + actualMinutes: 1800, + targetActualMinutes: 300, + targetMinutes: 240, + overtimeMinutes: 60, + targetEnabled: true, + }); + expect(await summary('2026-09-07')).toMatchObject({ + actualMinutes: 600, + targetEnabled: false, + targetActualMinutes: null, + targetMinutes: null, + overtimeMinutes: null, + }); + expect(await summary('2026-09-11')).toMatchObject({ + actualMinutes: 900, + targetEnabled: false, + overtimeMinutes: null, + }); + }); + + it('never applies a future/current policy to an earlier interval and preserves policy revisions', async () => { + const { policy, entry, summary } = await fixture(); + await policy('2026-09-08', { + targetEnabled: true, + weeklyTargetMinutes: 600, + leaveEnabled: true, + annualLeaveDays: 20, + }); + await policy('2026-09-10', { + targetEnabled: true, + weeklyTargetMinutes: 1200, + leaveEnabled: true, + annualLeaveDays: 25, + }); + await entry('2026-09-07T08:00:00Z', '2026-09-07T09:00:00Z'); + expect(await summary('2026-09-07')).toMatchObject({ + actualMinutes: 60, + targetMinutes: null, + overtimeMinutes: null, + leaveEnabled: false, + vacationDaysTotal: null, + }); + expect(await summary('2026-09-08')).toMatchObject({ + targetMinutes: 120, + vacationDaysTotal: 20, + }); + expect(await summary('2026-09-10')).toMatchObject({ + targetMinutes: 240, + vacationDaysTotal: 25, + }); + }); + + it('splits net duration at local midnight and keeps DST and sub-minute precision consistent with reports', async () => { + const { policy, entry, summary, authorization } = await fixture(); + await policy('2026-03-29', { + targetEnabled: true, + weeklyTargetMinutes: 420, + workingDays: 127, + }); + await entry('2026-03-28T22:30:00Z', '2026-03-29T02:30:00Z'); + expect(await summary('2026-03-29')).toMatchObject({ + actualMinutes: 210, + targetActualMinutes: 210, + targetMinutes: 60, + overtimeMinutes: 150, + }); + await policy('2026-09-01'); + await policy('2026-09-08', { + targetEnabled: true, + weeklyTargetMinutes: 150, + }); + await entry('2026-09-07T21:30:00Z', '2026-09-07T22:30:00Z'); + await entry('2026-09-08T08:00:00Z', '2026-09-08T08:00:30Z'); + const values = await summary('2026-09-07', '2026-09-08'); + expect(values).toMatchObject({ + actualMinutes: 60.5, + targetActualMinutes: 30.5, + targetMinutes: 30, + overtimeMinutes: 0.5, + }); + const report = await ctx.http + .get('/api/reports/solo?from=2026-09-07&to=2026-09-08') + .set('Authorization', authorization) + .expect(200); + expect(values.actualMinutes).toBe(report.body.totals.netMinutes); + }); + + it('retains legacy vacation/absence history without double counting overlapping sources', async () => { + const { owner, policy, day, summary } = await fixture(); + await policy('2026-05-04', { + targetEnabled: true, + weeklyTargetMinutes: 2400, + leaveEnabled: true, + annualLeaveDays: 20, + leaveAllowanceYear: 2026, + }); + // A personal free day recorded while its leave account was off is not + // retroactively turned into vacation consumption when the account starts. + await day('2026-03-02'); + await ctx.prisma.request.createMany({ + data: [ + { + employeeId: owner.id, + type: 'Vacation', + workflowState: 'Approved', + status: 'Approved', + from: new Date('2026-04-01'), + to: new Date('2026-04-01'), + calculatedDays: 1, + }, + { + employeeId: owner.id, + type: 'Vacation', + workflowState: 'Approved', + status: 'Approved', + from: new Date('2026-05-04'), + to: new Date('2026-05-05'), + halfDayStart: true, + calculatedDays: 1.5, + }, + { + employeeId: owner.id, + type: 'Vacation', + workflowState: 'Approved', + status: 'Approved', + from: new Date('2026-05-04'), + to: new Date('2026-05-05'), + halfDayStart: true, + calculatedDays: 1.5, + }, + { + employeeId: owner.id, + type: 'Vacation', + workflowState: 'Cancelled', + status: 'Cancelled', + from: new Date('2026-05-08'), + to: new Date('2026-05-08'), + calculatedDays: 1, + }, + ], + }); + await day('2026-05-04', '2026-05-05', { halfDayStart: true }); + await ctx.prisma.absence.createMany({ + data: [ + { + employeeId: owner.id, + kind: 'Sickness', + from: new Date('2026-05-06'), + to: new Date('2026-05-06'), + }, + { + employeeId: owner.id, + kind: 'Training', + from: new Date('2026-05-07'), + to: new Date('2026-05-07'), + }, + { + employeeId: owner.id, + kind: 'Flextime', + from: new Date('2026-05-08'), + to: new Date('2026-05-08'), + }, + ], + }); + const values = await summary('2026-05-04', '2026-05-08'); + expect(values).toMatchObject({ + targetMinutes: 720, + overtimeMinutes: -720, + vacationDaysTotal: 20, + vacationDaysUsed: 2.5, + vacationDaysRemaining: 17.5, + }); + }); + + it('preserves a legacy stored leave total even when the old working calendar is unavailable', async () => { + const { owner, policy, summary } = await fixture(); + await policy('2026-01-01', { + leaveEnabled: true, + annualLeaveDays: 20, + workingDays: 31, + }); + await ctx.prisma.request.create({ + data: { + employeeId: owner.id, + type: 'Vacation', + workflowState: 'Approved', + status: 'Approved', + from: new Date('2026-05-09'), + to: new Date('2026-05-09'), + calculatedDays: 1, + }, + }); + expect(await summary('2026-05-09')).toMatchObject({ + vacationDaysUsed: 1, + vacationDaysRemaining: 19, + }); + }); + + it('expires only unconsumed carry-over as of the requested date, keeping year-specific adjustments', async () => { + const { policy, day, summary } = await fixture(); + await policy('2026-01-01', { + leaveEnabled: true, + annualLeaveDays: 20, + carryOverDays: 5, + carryOverExpiresOn: new Date('2026-03-31'), + leaveAdjustmentDays: 2, + leaveAdjustmentReason: 'Explicit opening correction', + leaveAllowanceYear: 2026, + }); + await day('2026-01-05', '2026-01-06'); + await day('2026-03-31'); + expect(await summary('2026-01-01', '2026-01-04')).toMatchObject({ + vacationDaysTotal: 27, + vacationDaysUsed: 0, + vacationDaysCarryOverExpired: 0, + }); + expect(await summary('2026-03-31')).toMatchObject({ + vacationDaysTotal: 27, + vacationDaysUsed: 3, + vacationDaysRemaining: 24, + vacationDaysCarryOver: 5, + vacationDaysCarryOverExpired: 0, + }); + expect(await summary('2026-04-01')).toMatchObject({ + vacationDaysTotal: 25, + vacationDaysUsed: 3, + vacationDaysRemaining: 22, + vacationDaysCarryOver: 3, + vacationDaysCarryOverUsed: 3, + vacationDaysCarryOverExpired: 2, + }); + expect(await summary('2027-01-01')).toMatchObject({ + vacationDaysTotal: 20, + vacationDaysUsed: 0, + vacationDaysCarryOver: 0, + vacationDaysAdjustment: 0, + }); + }); + + it('allocates half days across the year boundary once and keeps the requested year balance separate', async () => { + const { owner, policy, day, summary } = await fixture(); + await policy('2026-01-01', { + leaveEnabled: true, + annualLeaveDays: 20, + carryOverDays: 3, + leaveAdjustmentDays: 1, + leaveAllowanceYear: 2026, + }); + await day('2026-12-31', '2027-01-04', { + halfDayStart: true, + halfDayEnd: true, + }); + await ctx.prisma.request.create({ + data: { + employeeId: owner.id, + type: 'Vacation', + workflowState: 'Approved', + status: 'Approved', + from: new Date('2026-12-31'), + to: new Date('2027-01-04'), + halfDayStart: true, + halfDayEnd: true, + calculatedDays: 2, + }, + }); + expect(await summary('2026-12-31')).toMatchObject({ + vacationAllowanceYear: 2026, + vacationDaysTotal: 24, + vacationDaysUsed: 0.5, + vacationDaysRemaining: 23.5, + }); + expect(await summary('2026-12-31', '2027-01-04')).toMatchObject({ + vacationAllowanceYear: 2027, + vacationDaysTotal: 20, + vacationDaysUsed: 1.5, + vacationDaysRemaining: 18.5, + }); + }); + + it('does not accrue Solo targets while the installation explicitly operated in Team mode', async () => { + const { owner, policy, entry, summary } = await fixture(); + await policy('2026-09-07', { + targetEnabled: true, + weeklyTargetMinutes: 600, + }); + await ctx.prisma.installationEvent.createMany({ + data: [ + { + actorId: owner.id, + action: 'ModeChanged', + before: { mode: 'Solo', ownerEmployeeId: owner.id }, + after: { mode: 'Team', ownerEmployeeId: owner.id }, + occurredAt: new Date('2026-09-08T22:00:00Z'), + }, + { + actorId: owner.id, + action: 'ModeChanged', + before: { mode: 'Team', ownerEmployeeId: owner.id }, + after: { mode: 'Solo', ownerEmployeeId: owner.id }, + occurredAt: new Date('2026-09-10T22:00:00Z'), + }, + ], + }); + for (const date of ['07', '08', '09', '10', '11']) + await entry(`2026-09-${date}T06:00:00Z`, `2026-09-${date}T08:00:00Z`); + expect(await summary('2026-09-07', '2026-09-11')).toMatchObject({ + actualMinutes: 600, + targetActualMinutes: 360, + targetMinutes: 360, + overtimeMinutes: 0, + }); + expect(await summary('2026-09-09')).toMatchObject({ + actualMinutes: 120, + targetEnabled: false, + targetMinutes: null, + overtimeMinutes: null, + }); + }); + + it('honors a deferred accounting date so an immediate access-mode switch does not rewrite today', async () => { + const { owner, policy, entry, summary } = await fixture(); + await policy('2026-09-07', { + targetEnabled: true, + weeklyTargetMinutes: 600, + }); + await ctx.prisma.installationEvent.createMany({ + data: [ + { + actorId: owner.id, + action: 'ModeChanged', + before: { mode: 'Solo', ownerEmployeeId: owner.id }, + after: { + mode: 'Team', + ownerEmployeeId: owner.id, + accountingEffectiveFrom: '2026-09-10', + }, + occurredAt: new Date('2026-09-09T12:00:00Z'), + }, + { + actorId: owner.id, + action: 'ModeChanged', + before: { mode: 'Team', ownerEmployeeId: owner.id }, + after: { + mode: 'Solo', + ownerEmployeeId: owner.id, + accountingEffectiveFrom: '2026-09-11', + }, + occurredAt: new Date('2026-09-11T06:00:00Z'), + }, + ], + }); + await entry('2026-09-09T06:00:00Z', '2026-09-09T08:00:00Z'); + await entry('2026-09-10T06:00:00Z', '2026-09-10T08:00:00Z'); + expect(await summary('2026-09-09')).toMatchObject({ + actualMinutes: 120, + targetActualMinutes: 120, + targetMinutes: 120, + overtimeMinutes: 0, + }); + expect(await summary('2026-09-10')).toMatchObject({ + actualMinutes: 120, + targetActualMinutes: null, + targetMinutes: null, + overtimeMinutes: null, + }); + expect(await summary('2026-09-11')).toMatchObject({ + targetEnabled: true, + targetMinutes: 120, + }); + }); + + it('validates period boundaries and ignores voided, rejected, open and foreign work', async () => { + const { owner, authorization, policy, entry, summary } = await fixture(); + await policy('2026-09-01'); + await entry('2026-09-07T08:00:00Z', '2026-09-07T09:00:00Z'); + await entry('2026-09-07T09:00:00Z', '2026-09-07T10:00:00Z', { + voidedAt: new Date(), + }); + await entry('2026-09-07T10:00:00Z', '2026-09-07T11:00:00Z', { + status: 'Rejected', + }); + await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date('2026-09-07T12:00:00Z'), + status: 'Open', + }, + }); + const other = await seedEmployee(ctx.prisma, { + personalNo: 'FOREIGN', + firstName: 'Old', + lastName: 'Employee', + email: 'summary-foreign@test.local', + }); + await entry('2026-09-07T08:00:00Z', '2026-09-07T16:00:00Z', { + employeeId: other.id, + }); + expect(await summary('2026-09-07')).toMatchObject({ + actualMinutes: 60, + targetEnabled: false, + leaveEnabled: false, + }); + for (const query of [ + 'from=2026-02-31&to=2026-03-01', + 'from=2026-09-08&to=2026-09-07', + 'from=2025-01-01&to=2026-01-02', + ]) + await ctx.http + .get(`/api/installation/summary?${query}`) + .set('Authorization', authorization) + .expect(400); + }); +}); diff --git a/apps/api-e2e/src/api/solo-time-hints.e2e.spec.ts b/apps/api-e2e/src/api/solo-time-hints.e2e.spec.ts new file mode 100644 index 0000000..e04d194 --- /dev/null +++ b/apps/api-e2e/src/api/solo-time-hints.e2e.spec.ts @@ -0,0 +1,293 @@ +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +describe('Personal core and frame hints', () => { + let ctx: TestContext; + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture(enabled = true) { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'HINT-OWNER', + firstName: 'Personal', + lastName: 'Hints', + email: 'hints@test.local', + role: 'HRAdmin', + }); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: owner.id }, + update: { mode: 'Solo', ownerEmployeeId: owner.id }, + }); + const policy = await ctx.prisma.soloPolicy.create({ + data: { + employeeId: owner.id, + effectiveFrom: new Date('2025-01-01'), + coreTimeHintsEnabled: enabled, + frameStart: '08:00', + frameEnd: '18:00', + coreTimes: [ + { start: '09:00', end: '15:00', weekdays: 31, label: 'Focus' }, + ], + }, + }); + const auth = `Bearer ${await login(ctx.http, owner.email)}`; + return { owner, policy, auth }; + } + + async function entry( + employeeId: string, + date: string, + from: string, + to: string, + state: 'Approved' | 'Rejected' = 'Approved', + voided = false, + ) { + return ctx.prisma.timeEntry.create({ + data: { + employeeId, + clockIn: new Date(`${date}T${from}:00`), + clockOut: new Date(`${date}T${to}:00`), + status: state, + approvalMode: 'Solo', + voidedAt: voided ? new Date() : null, + }, + }); + } + + it('is all-off by default and does not invent gaps on empty days or for an empty core configuration', async () => { + const { owner, policy, auth } = await fixture(false); + await entry(owner.id, '2025-09-02', '10:00', '14:00'); + const disabled = await ctx.http + .get('/api/installation/hints?from=2025-09-01&to=2025-09-05') + .set('Authorization', auth) + .expect(200); + expect(disabled.body).toEqual({ enabled: false, hints: [] }); + await ctx.prisma.soloPolicy.update({ + where: { id: policy.id }, + data: { + coreTimeHintsEnabled: true, + coreTimes: [], + frameStart: '00:00', + frameEnd: '23:59', + }, + }); + const empty = await ctx.http + .get('/api/installation/hints?from=2025-09-01&to=2025-09-05') + .set('Authorization', auth) + .expect(200); + expect(empty.body).toEqual({ enabled: true, hints: [] }); + expect(await ctx.prisma.request.count()).toBe(0); + }); + + it('reports the configured missing periods and actual work outside the preferred frame without changing approval', async () => { + const { owner, auth } = await fixture(); + await entry(owner.id, '2025-09-02', '07:30', '08:30'); + await entry(owner.id, '2025-09-02', '10:00', '14:00'); + await entry(owner.id, '2025-09-02', '18:30', '19:00'); + const result = await ctx.http + .get('/api/installation/hints?from=2025-09-02&to=2025-09-02') + .set('Authorization', auth) + .expect(200); + expect(result.body.enabled).toBe(true); + expect(result.body.hints).toEqual( + expect.arrayContaining([ + { + date: '2025-09-02', + kind: 'BeforeFrame', + boundary: '08:00–18:00', + deltaMinutes: 30, + }, + { + date: '2025-09-02', + kind: 'AfterFrame', + boundary: '08:00–18:00', + deltaMinutes: 30, + }, + { + date: '2025-09-02', + kind: 'LateArrival', + boundary: '09:00–15:00', + deltaMinutes: 60, + windowLabel: 'Focus', + }, + { + date: '2025-09-02', + kind: 'EarlyDeparture', + boundary: '09:00–15:00', + deltaMinutes: 60, + windowLabel: 'Focus', + }, + ]), + ); + expect(result.body.hints).toHaveLength(4); + expect( + await ctx.prisma.timeEntry.count({ where: { requiresApproval: true } }), + ).toBe(0); + expect(await ctx.prisma.request.count()).toBe(0); + }); + + it('uses the policy effective on each day and ignores later/future windows', async () => { + const { owner, auth } = await fixture(); + await ctx.prisma.soloPolicy.create({ + data: { + employeeId: owner.id, + effectiveFrom: new Date('2025-09-03'), + coreTimeHintsEnabled: true, + coreTimes: [{ start: '10:00', end: '14:00', weekdays: 31 }], + }, + }); + await ctx.prisma.soloPolicy.create({ + data: { + employeeId: owner.id, + effectiveFrom: new Date('2040-01-01'), + coreTimeHintsEnabled: true, + coreTimes: [{ start: '07:00', end: '20:00', weekdays: 127 }], + }, + }); + await entry(owner.id, '2025-09-02', '10:00', '14:00'); + await entry(owner.id, '2025-09-04', '10:00', '14:00'); + const result = await ctx.http + .get('/api/installation/hints?from=2025-09-02&to=2025-09-04') + .set('Authorization', auth) + .expect(200); + expect(result.body.hints).toHaveLength(2); + expect( + result.body.hints.every( + (hint: { date: string; boundary: string }) => + hint.date === '2025-09-02' && hint.boundary === '09:00–15:00', + ), + ).toBe(true); + }); + + it('suppresses free days, holidays, weekends, rejected/voided work and open timers', async () => { + const { owner, policy, auth } = await fixture(); + await ctx.prisma.soloPolicy.update({ + where: { id: policy.id }, + data: { holidayDates: ['2025-09-03'] }, + }); + await ctx.prisma.personalDay.create({ + data: { + employeeId: owner.id, + kind: 'Free', + from: new Date('2025-09-02'), + to: new Date('2025-09-02'), + halfDayStart: true, + }, + }); + for (const date of ['2025-09-02', '2025-09-03', '2025-09-06']) + await entry(owner.id, date, '10:00', '14:00'); + await entry(owner.id, '2025-09-04', '10:00', '14:00', 'Rejected'); + await entry(owner.id, '2025-09-05', '10:00', '14:00', 'Approved', true); + await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date('2025-09-01T10:00:00'), + approvalMode: 'Solo', + }, + }); + const result = await ctx.http + .get('/api/installation/hints?from=2025-09-01&to=2025-09-07') + .set('Authorization', auth) + .expect(200); + expect(result.body.hints).toEqual([]); + }); + + it('includes the local-day part of an overnight entry whose start precedes the period', async () => { + const { owner, auth } = await fixture(); + await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date('2025-09-01T23:00:00Z'), + clockOut: new Date('2025-09-02T08:00:00Z'), + status: 'Approved', + approvalMode: 'Solo', + }, + }); + const result = await ctx.http + .get('/api/installation/hints?from=2025-09-02&to=2025-09-02') + .set('Authorization', auth) + .expect(200); + expect(result.body.hints).toEqual( + expect.arrayContaining([ + { + date: '2025-09-02', + kind: 'BeforeFrame', + boundary: '08:00–18:00', + deltaMinutes: 420, + }, + { + date: '2025-09-02', + kind: 'EarlyDeparture', + boundary: '09:00–15:00', + deltaMinutes: 300, + windowLabel: 'Focus', + }, + ]), + ); + }); + + it('validates configured intervals, rejects conflicting windows and persists a valid policy version', async () => { + const { auth } = await fixture(); + const state = await ctx.http + .get('/api/installation') + .set('Authorization', auth) + .expect(200); + const settings = { + ...state.body.policy, + revision: state.body.revision, + effectiveFrom: '2040-01-01', + }; + for (const invalid of [ + { frameStart: '18:00', frameEnd: '08:00' }, + { coreTimes: [{ start: '15:00', end: '09:00', weekdays: 31 }] }, + { + coreTimes: [ + { start: '09:00', end: '12:00', weekdays: 31 }, + { start: '11:00', end: '13:00', weekdays: 1 }, + ], + }, + { coreTimes: [{ start: '07:00', end: '09:00', weekdays: 31 }] }, + ]) + await ctx.http + .patch('/api/installation/settings') + .set('Authorization', auth) + .send({ ...settings, ...invalid }) + .expect(400); + const changed = await ctx.http + .patch('/api/installation/settings') + .set('Authorization', auth) + .send({ + ...settings, + frameStart: '07:00', + frameEnd: '19:00', + coreTimes: [ + { start: '10:00', end: '12:00', weekdays: 31, label: 'Deep work' }, + ], + }) + .expect(200); + expect( + changed.body.futurePolicies.find( + (policy: { effectiveFrom: string }) => + policy.effectiveFrom === '2040-01-01', + ), + ).toMatchObject({ + frameStart: '07:00', + frameEnd: '19:00', + coreTimes: [ + { start: '10:00', end: '12:00', weekdays: 31, label: 'Deep work' }, + ], + }); + }); +}); diff --git a/apps/api-e2e/src/api/solo-time.e2e.spec.ts b/apps/api-e2e/src/api/solo-time.e2e.spec.ts new file mode 100644 index 0000000..d49ad39 --- /dev/null +++ b/apps/api-e2e/src/api/solo-time.e2e.spec.ts @@ -0,0 +1,639 @@ +import { randomUUID } from 'node:crypto'; +import { calculateCaptureSummaries } from '../../../api/src/app/time-entries/capture-summary'; +import { + createTestApp, + login, + seedEmployee, + seedProject, + type TestContext, +} from '../support/test-app'; + +const interval = { + clockIn: '2025-09-02T08:00:00.000Z', + clockOut: '2025-09-02T15:00:00.000Z', +}; +const rules = [{ afterMinutes: 360, breakMinutes: 30 }]; + +describe('Solo working-time actions', () => { + let ctx: TestContext; + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture(breakRules = rules) { + const owner = await seedEmployee(ctx.prisma, { + personalNo: 'SOLO-1', + firstName: 'Solo', + lastName: 'Owner', + email: 'time-owner@test.local', + role: 'HRAdmin', + }); + await ctx.prisma.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: 'Solo', ownerEmployeeId: owner.id, setupCompleted: true }, + update: { mode: 'Solo', ownerEmployeeId: owner.id, setupCompleted: true }, + }); + const policy = await ctx.prisma.soloPolicy.create({ + data: { + employeeId: owner.id, + effectiveFrom: new Date('2020-01-01'), + breakRules, + }, + }); + const token = await login(ctx.http, owner.email); + const auth = `Bearer ${token}`; + return { owner, policy, auth }; + } + + it('records metadata, exact net time and private notes immediately without a request', async () => { + const { owner, auth } = await fixture(); + const result = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ + ...interval, + activity: 'Customer implementation', + note: 'Private review note', + billable: true, + }) + .expect(201); + expect(result.body).toMatchObject({ + employeeId: owner.id, + source: 'Manual', + status: 'Approved', + requiresApproval: false, + approvalMode: 'Solo', + revision: 0, + billable: true, + note: 'Private review note', + summary: { grossMinutes: 420, breakMinutes: 30, netMinutes: 390 }, + }); + expect(result.body.captureGroupId).toBeTruthy(); + expect(await ctx.prisma.request.count()).toBe(0); + const audit = await ctx.http + .get(`/api/timeentries/${result.body.id}/audit`) + .set('Authorization', auth) + .expect(200); + expect(audit.body).toHaveLength(1); + expect(audit.body[0]).toMatchObject({ + action: 'ManualCreated', + actorId: owner.id, + before: null, + after: { note: 'Private review note', summary: result.body.summary }, + }); + }); + + it('requires offset-resolved positive past intervals and rejects overlaps while allowing adjacent work', async () => { + const { auth } = await fixture([]); + for (const data of [ + { clockIn: interval.clockOut, clockOut: interval.clockIn }, + { clockIn: interval.clockIn, clockOut: interval.clockIn }, + { clockIn: '2025-09-02T08:00:00', clockOut: interval.clockOut }, + { clockIn: interval.clockIn, clockOut: '2099-01-01T08:00:00Z' }, + ]) + await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(data) + .expect(400); + await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(interval) + .expect(201); + await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ + clockIn: '2025-09-02T14:00:00Z', + clockOut: '2025-09-02T16:00:00Z', + }) + .expect(409); + await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ clockIn: interval.clockOut, clockOut: '2025-09-02T16:00:00Z' }) + .expect(201); + }); + + it('serializes concurrent manual inserts and does not create a losing audit row', async () => { + const { auth } = await fixture(); + const responses = await Promise.all( + [1, 2].map(() => + ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(interval), + ), + ); + expect(responses.map((response) => response.status).sort()).toEqual([ + 201, 409, + ]); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + expect(await ctx.prisma.timeEntryAudit.count()).toBe(1); + }); + + it('protects corrections with revision, overlap detection, reasons and before/after summaries', async () => { + const { auth } = await fixture(); + const created = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(interval) + .expect(201); + const id = created.body.id; + await ctx.http + .patch(`/api/timeentries/${id}/correct`) + .set('Authorization', auth) + .send({ ...interval, revision: 0, reason: ' ' }) + .expect(400); + const responses = await Promise.all([ + ctx.http + .patch(`/api/timeentries/${id}/correct`) + .set('Authorization', auth) + .send({ + ...interval, + clockOut: '2025-09-02T16:00:00Z', + revision: 0, + reason: 'Forgot final hour', + }), + ctx.http + .patch(`/api/timeentries/${id}/correct`) + .set('Authorization', auth) + .send({ + ...interval, + clockOut: '2025-09-02T16:00:00Z', + revision: 0, + reason: 'Other browser tab', + }), + ]); + expect(responses.map((response) => response.status).sort()).toEqual([ + 200, 409, + ]); + expect( + responses.find((response) => response.status === 200)?.body, + ).toMatchObject({ + revision: 1, + status: 'Approved', + summary: { grossMinutes: 480, breakMinutes: 30, netMinutes: 450 }, + }); + await ctx.http + .patch(`/api/timeentries/${id}`) + .set('Authorization', auth) + .send({ activity: 'Stale change', revision: 0 }) + .expect(409); + const audit = await ctx.http + .get(`/api/timeentries/${id}/audit`) + .set('Authorization', auth) + .expect(200); + expect(audit.body).toHaveLength(2); + expect( + audit.body.find( + (event: { action: string }) => event.action === 'Corrected', + ), + ).toMatchObject({ + before: { revision: 0, summary: { netMinutes: 390 } }, + after: { revision: 1, summary: { netMinutes: 450 } }, + }); + }); + + it('keeps a voided entry and audit, excludes its totals, and permits replacing the interval', async () => { + const { owner, auth } = await fixture(); + const created = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(interval) + .expect(201); + const result = await ctx.http + .post(`/api/timeentries/${created.body.id}/void`) + .set('Authorization', auth) + .send({ revision: 0, reason: 'Duplicate work imported' }) + .expect(201); + expect(result.body).toMatchObject({ revision: 1, summary: null }); + expect(result.body.voidedAt).toBeTruthy(); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + const list = await ctx.http + .get(`/api/timeentries?employeeId=${owner.id}`) + .set('Authorization', auth) + .expect(200); + expect(list.body[0].summary).toBeNull(); + await ctx.http + .patch(`/api/timeentries/${created.body.id}/correct`) + .set('Authorization', auth) + .send({ ...interval, revision: 1, reason: 'Attempt to reuse' }) + .expect(409); + await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(interval) + .expect(201); + expect( + await ctx.prisma.timeEntryAudit.count({ where: { action: 'Voided' } }), + ).toBe(1); + }); + + it('requires owner scope even when the owner has the HRAdmin role', async () => { + const { auth } = await fixture(); + const former = await seedEmployee(ctx.prisma, { + personalNo: 'OLD', + firstName: 'Former', + lastName: 'Employee', + email: 'former@test.local', + }); + await ctx.prisma.employee.update({ + where: { id: former.id }, + data: { isActive: false }, + }); + const entry = await ctx.prisma.timeEntry.create({ + data: { + employeeId: former.id, + clockIn: new Date(interval.clockIn), + clockOut: new Date(interval.clockOut), + status: 'Approved', + }, + }); + await ctx.http + .get(`/api/timeentries?employeeId=${former.id}`) + .set('Authorization', auth) + .expect(403); + await ctx.http + .get(`/api/timeentries/${entry.id}/audit`) + .set('Authorization', auth) + .expect(403); + await ctx.http + .patch(`/api/timeentries/${entry.id}/correct`) + .set('Authorization', auth) + .send({ ...interval, revision: 0, reason: 'Forbidden foreign change' }) + .expect(403); + await ctx.http + .post(`/api/timeentries/${entry.id}/void`) + .set('Authorization', auth) + .send({ revision: 0, reason: 'Forbidden foreign void' }) + .expect(403); + await ctx.http + .patch(`/api/timeentries/${entry.id}`) + .set('Authorization', auth) + .send({ activity: 'Foreign change' }) + .expect(403); + }); + + it('preserves group gross, break and net through repeated splits and range bookings', async () => { + const { owner, auth } = await fixture(); + const project = await seedProject(ctx.prisma, { + code: 'SOLO-PROJECT', + assigneeIds: [owner.id], + }); + const created = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ ...interval, clockOut: '2025-09-02T15:00:00.750Z' }) + .expect(201); + const first = await ctx.http + .post(`/api/timeentries/${created.body.id}/split`) + .set('Authorization', auth) + .send({ at: '2025-09-02T13:00:00Z', revision: 0, projectId: project.id }) + .expect(201); + expect(first.body.first.captureGroupId).toBe( + first.body.second.captureGroupId, + ); + const totalBefore = created.body.summary; + const range = await ctx.http + .post('/api/timeentries/book-project') + .set('Authorization', auth) + .send({ + employeeId: owner.id, + from: '2025-09-02T09:00:00.500Z', + to: '2025-09-02T14:00:00Z', + revisions: [first.body.first, first.body.second].map((entry) => ({ + id: entry.id, + revision: entry.revision, + })), + projectId: project.id, + }) + .expect(201); + expect(range.body.entries.length).toBeGreaterThan(2); + const list = await ctx.http + .get(`/api/timeentries?employeeId=${owner.id}`) + .set('Authorization', auth) + .expect(200); + const total = list.body.reduce( + ( + sum: { grossMinutes: number; breakMinutes: number; netMinutes: number }, + entry: { + summary: { + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + }; + }, + ) => ({ + grossMinutes: sum.grossMinutes + entry.summary.grossMinutes, + breakMinutes: sum.breakMinutes + entry.summary.breakMinutes, + netMinutes: sum.netMinutes + entry.summary.netMinutes, + }), + { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 }, + ); + for (const key of ['grossMinutes', 'breakMinutes', 'netMinutes'] as const) + expect(total[key]).toBeCloseTo(totalBefore[key], 9); + const subset = await ctx.http + .get( + `/api/timeentries?employeeId=${owner.id}&from=2025-09-02T13:30:00Z&to=2025-09-02T13:45:00Z`, + ) + .set('Authorization', auth) + .expect(200); + expect(subset.body).toHaveLength(1); + expect(subset.body[0].summary).toEqual( + list.body.find((entry: { id: string }) => entry.id === subset.body[0].id) + .summary, + ); + }); + + it('keeps break and approval snapshots across a live switch and a policy change', async () => { + const { owner, policy, auth } = await fixture(); + const started = await ctx.http + .post('/api/timeentries/clock-in') + .set('Authorization', auth) + .send({ + note: 'Private starting note', + latitude: 51, + longitude: 7, + accuracyMeters: 10, + }) + .expect(201); + expect(started.body).toMatchObject({ + approvalMode: 'Solo', + requiresApproval: false, + latitude: null, + longitude: null, + }); + await ctx.prisma.timeEntry.update({ + where: { id: started.body.id }, + data: { clockIn: new Date(Date.now() - 7 * 60 * 60_000) }, + }); + await ctx.prisma.soloPolicy.update({ + where: { id: policy.id }, + data: { breakRules: [] }, + }); + const switched = await ctx.http + .post(`/api/timeentries/${started.body.id}/switch-project`) + .set('Authorization', auth) + .send({ revision: 0, activity: 'Second activity' }) + .expect(201); + expect(switched.body.first.captureGroupId).toEqual( + switched.body.second.captureGroupId, + ); + await ctx.http + .post('/api/timeentries/clock-out') + .set('Authorization', auth) + .send({ id: started.body.id, revision: 0 }) + .expect(409); + const stopped = await ctx.http + .post('/api/timeentries/clock-out') + .set('Authorization', auth) + .send({ id: switched.body.second.id, revision: 0 }) + .expect(201); + expect(stopped.body).toMatchObject({ + status: 'Approved', + requiresApproval: false, + }); + const list = await ctx.http + .get(`/api/timeentries?employeeId=${owner.id}`) + .set('Authorization', auth) + .expect(200); + expect( + list.body.reduce( + (sum: number, entry: { summary: { breakMinutes: number } }) => + sum + entry.summary.breakMinutes, + 0, + ), + ).toBeCloseTo(30, 9); + expect( + list.body.reduce( + (sum: number, entry: { summary: { netMinutes: number } }) => + sum + entry.summary.netMinutes, + 0, + ), + ).toBeCloseTo(390, 0); + }); + + it('serializes duplicate switches and requires timer identity and revision on stop', async () => { + const { auth } = await fixture([]); + const started = await ctx.http + .post('/api/timeentries/clock-in') + .set('Authorization', auth) + .send({}) + .expect(201); + await ctx.http + .post('/api/timeentries/clock-out') + .set('Authorization', auth) + .send({}) + .expect(400); + const responses = await Promise.all( + [1, 2].map(() => + ctx.http + .post(`/api/timeentries/${started.body.id}/switch-project`) + .set('Authorization', auth) + .send({ revision: 0 }), + ), + ); + expect(responses.map((response) => response.status).sort()).toEqual([ + 201, 409, + ]); + expect( + await ctx.prisma.timeEntry.count({ + where: { clockOut: null, voidedAt: null }, + }), + ).toBe(1); + }); + + it('can correct a forgotten running timer and void a running timer without blocking a new one', async () => { + const { auth } = await fixture([]); + const started = await ctx.http + .post('/api/timeentries/clock-in') + .set('Authorization', auth) + .send({}) + .expect(201); + const corrected = await ctx.http + .patch(`/api/timeentries/${started.body.id}/correct`) + .set('Authorization', auth) + .send({ ...interval, revision: 0, reason: 'Forgot to stop yesterday' }) + .expect(200); + expect(corrected.body).toMatchObject({ + status: 'Approved', + summary: { netMinutes: 420 }, + }); + const newTimer = await ctx.http + .post('/api/timeentries/clock-in') + .set('Authorization', auth) + .send({}) + .expect(201); + await ctx.http + .post(`/api/timeentries/${newTimer.body.id}/void`) + .set('Authorization', auth) + .send({ revision: 0, reason: 'Accidental click' }) + .expect(201); + await ctx.http + .post('/api/timeentries/clock-in') + .set('Authorization', auth) + .send({}) + .expect(201); + }); + + it('uses project/order billable defaults and keeps archived historical bookings editable', async () => { + const { owner, auth } = await fixture([]); + const project = await ctx.prisma.project.create({ + data: { + code: 'BILLABLE', + name: 'Client work', + defaultBillable: true, + assignments: { create: { employeeId: owner.id } }, + }, + }); + const created = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ ...interval, projectId: project.id }) + .expect(201); + expect(created.body.billable).toBe(true); + await ctx.prisma.project.update({ + where: { id: project.id }, + data: { isActive: false }, + }); + await ctx.http + .patch(`/api/timeentries/${created.body.id}/correct`) + .set('Authorization', auth) + .send({ + ...interval, + revision: 0, + activity: 'Correct historical description', + note: 'Private context', + reason: 'Clarify the work', + }) + .expect(200); + await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ + clockIn: '2025-09-03T08:00:00Z', + clockOut: '2025-09-03T09:00:00Z', + projectId: project.id, + }) + .expect(400); + }); + + it('resolves historical Solo break rules at the entry start and does not apply new defaults retroactively', async () => { + const { owner, auth } = await fixture([]); + await ctx.prisma.soloPolicy.create({ + data: { + employeeId: owner.id, + effectiveFrom: new Date('2025-09-03'), + breakRules: rules, + }, + }); + const past = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send(interval) + .expect(201); + expect(past.body.summary.breakMinutes).toBe(0); + const later = await ctx.http + .post('/api/timeentries/manual') + .set('Authorization', auth) + .send({ + clockIn: '2025-09-03T08:00:00Z', + clockOut: '2025-09-03T15:00:00Z', + }) + .expect(201); + expect(later.body.summary.breakMinutes).toBe(30); + }); + + it('uses personal target settings for daily blocks without requiring the Team employee feature', async () => { + const { auth, policy } = await fixture([]); + await ctx.prisma.soloPolicy.update({ + where: { id: policy.id }, + data: { + targetEnabled: true, + weeklyTargetMinutes: 840, + workingDays: 127, + dailyBlockEnabled: true, + }, + }); + const option = await ctx.http + .get('/api/timeentries/daily-block/option') + .set('Authorization', auth) + .expect(200); + expect(option.body).toMatchObject({ enabled: true, dailyNetMinutes: 120 }); + const created = await ctx.http + .post('/api/timeentries/daily-block') + .set('Authorization', auth) + .send({ date: '2025-09-02', start: '00:01' }) + .expect(201); + expect(created.body).toMatchObject({ + status: 'Approved', + approvalMode: 'Solo', + requiresApproval: false, + summary: { grossMinutes: 120, netMinutes: 120 }, + }); + await ctx.http + .post(`/api/timeentries/${created.body.id}/void`) + .set('Authorization', auth) + .send({ revision: 0, reason: 'Rebook at correct time' }) + .expect(201); + await ctx.http + .post('/api/timeentries/daily-block') + .set('Authorization', auth) + .send({ date: '2025-09-02', start: '01:01' }) + .expect(201); + }); +}); + +describe('Capture duration allocation', () => { + const employeeId = randomUUID(); + const captureGroupId = randomUUID(); + const part = (id: string, start: string, end: string | null) => ({ + id, + employeeId, + captureGroupId, + clockIn: new Date(start), + clockOut: end ? new Date(end) : null, + breakRules: rules, + }); + + it('retains millisecond precision across fragments and excludes voided/rejected work', () => { + const rows = [ + part('a', interval.clockIn, '2025-09-02T13:00:00.500Z'), + part('b', '2025-09-02T13:00:00.500Z', '2025-09-02T15:00:00.750Z'), + ]; + const sums = calculateCaptureSummaries([ + ...rows, + { + ...part('void', interval.clockIn, interval.clockOut), + voidedAt: new Date(), + }, + { + ...part('rejected', interval.clockIn, interval.clockOut), + status: 'Rejected', + }, + ]); + expect(sums.size).toBe(2); + expect( + [...sums.values()].reduce((sum, item) => sum + item.grossMinutes, 0), + ).toBeCloseTo(420.0125, 10); + expect( + [...sums.values()].reduce((sum, item) => sum + item.breakMinutes, 0), + ).toBeCloseTo(30, 10); + }); + + it('only includes running work when a caller explicitly asks for a provisional duration', () => { + const row = part('open', interval.clockIn, null); + expect(calculateCaptureSummaries([row]).size).toBe(0); + expect( + calculateCaptureSummaries([row], new Date(interval.clockOut)).get('open'), + ).toEqual({ grossMinutes: 420, breakMinutes: 30, netMinutes: 390 }); + }); +}); diff --git a/apps/api-e2e/src/support/database-target.ts b/apps/api-e2e/src/support/database-target.ts new file mode 100644 index 0000000..38640f5 --- /dev/null +++ b/apps/api-e2e/src/support/database-target.ts @@ -0,0 +1,16 @@ +// Runtime-only operator helper: keep external source out of this TS rootDir. +const safety = require('../../../../ops/db-target-safety.cjs') as { + assertE2eTarget(env?: NodeJS.ProcessEnv): { + databaseUrl: string; + databaseName: string; + adminUrl: string; + }; + assertConnectedDatabase( + target: { databaseName: string }, + databaseName: string, + schemaName: string, + ): void; +}; + +export const assertE2eTarget = safety.assertE2eTarget; +export const assertConnectedDatabase = safety.assertConnectedDatabase; diff --git a/apps/api-e2e/src/support/global-setup.ts b/apps/api-e2e/src/support/global-setup.ts index fc07b37..f2d869f 100644 --- a/apps/api-e2e/src/support/global-setup.ts +++ b/apps/api-e2e/src/support/global-setup.ts @@ -1,19 +1,13 @@ import { execFileSync } from 'node:child_process'; import { Client } from 'pg'; +import { assertE2eTarget } from './database-target'; /* eslint-disable */ declare const globalThis: { __TEARDOWN_MESSAGE__?: string }; -const TEST_DB_NAME = 'openclockwork_test'; -const ADMIN_URL = - process.env.E2E_ADMIN_DATABASE_URL ?? - 'postgresql://openclockwork:openclockwork@localhost:5433/postgres'; -const TEST_DATABASE_URL = - process.env.E2E_DATABASE_URL ?? - `postgresql://openclockwork:openclockwork@localhost:5433/${TEST_DB_NAME}?schema=public`; - module.exports = async function () { - process.env.DATABASE_URL = TEST_DATABASE_URL; + const target = assertE2eTarget(); + process.env.DATABASE_URL = target.databaseUrl; process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'e2e-test-secret-change-me'; process.env.ERP_API_KEY = process.env.ERP_API_KEY ?? 'e2e-erp-key'; @@ -22,15 +16,16 @@ module.exports = async function () { process.env.API_PORT = process.env.API_PORT ?? '0'; // 1. Make sure the test database exists. - const admin = new Client({ connectionString: ADMIN_URL }); + const admin = new Client({ connectionString: target.adminUrl }); await admin.connect(); try { const exists = await admin.query( 'SELECT 1 FROM pg_database WHERE datname = $1', - [TEST_DB_NAME], + [target.databaseName], ); if (exists.rowCount === 0) { - await admin.query(`CREATE DATABASE "${TEST_DB_NAME}"`); + // The guard permits only a fixed test prefix and identifier-safe suffix. + await admin.query(`CREATE DATABASE "${target.databaseName}"`); } } finally { await admin.end(); @@ -40,7 +35,7 @@ module.exports = async function () { const prismaCli = require.resolve('prisma/build/index.js'); execFileSync(process.execPath, [prismaCli, 'migrate', 'deploy'], { stdio: 'inherit', - env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL }, + env: { ...process.env, DATABASE_URL: target.databaseUrl }, }); globalThis.__TEARDOWN_MESSAGE__ = '\nE2E teardown complete.\n'; diff --git a/apps/api-e2e/src/support/test-app.ts b/apps/api-e2e/src/support/test-app.ts index fb33e96..6c6eb4a 100644 --- a/apps/api-e2e/src/support/test-app.ts +++ b/apps/api-e2e/src/support/test-app.ts @@ -6,6 +6,7 @@ import * as bcrypt from 'bcrypt'; import { AppModule } from '../../../api/src/app/app.module'; import { PrismaService } from '../../../api/src/app/prisma/prisma.service'; import * as request from 'supertest'; +import { assertConnectedDatabase, assertE2eTarget } from './database-target'; export interface TestContext { app: INestApplication; @@ -17,6 +18,12 @@ export interface TestContext { const RESET_SQL = ` TRUNCATE TABLE + "InstallationSettings", + "InstallationEvent", + "SoloPolicy", + "PersonalDay", + "TimeEntryAudit", + "Customer", "TerminalChallengeRedemption", "TerminalChallenge", "TerminalDevice", @@ -38,6 +45,8 @@ const RESET_SQL = ` `; export async function createTestApp(): Promise { + const target = assertE2eTarget(); + process.env.DATABASE_URL = target.databaseUrl; const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile(); @@ -60,6 +69,17 @@ export async function createTestApp(): Promise { prisma, http, reset: async () => { + const current = assertE2eTarget(); + if (current.databaseUrl !== target.databaseUrl) + throw new Error( + 'Refusing E2E reset: the selected connection changed after app creation.', + ); + const [connected] = await prisma.$queryRaw< + Array<{ database: string; schema: string }> + >` + SELECT current_database() AS database, current_schema() AS schema + `; + assertConnectedDatabase(target, connected.database, connected.schema); await prisma.$executeRawUnsafe(RESET_SQL); }, close: async () => { diff --git a/apps/api-e2e/src/support/test-setup.ts b/apps/api-e2e/src/support/test-setup.ts index 1b3c9d1..290b263 100644 --- a/apps/api-e2e/src/support/test-setup.ts +++ b/apps/api-e2e/src/support/test-setup.ts @@ -1,12 +1,11 @@ +import { assertE2eTarget } from './database-target'; + /* eslint-disable */ // Per-spec setup runs in each test worker BEFORE the test file (and therefore // AppModule + ConfigModule) is loaded. Defaults set in globalSetup do not -// propagate to workers, so we mirror them here. A real .env value already -// in process.env wins (devs may override locally). -process.env.DATABASE_URL = - process.env.DATABASE_URL ?? - process.env.E2E_DATABASE_URL ?? - 'postgresql://openclockwork:openclockwork@localhost:5433/openclockwork_test?schema=public'; +// propagate to workers, so validate independently here. An unrelated inherited +// DATABASE_URL must never win over the explicitly selected test database. +process.env.DATABASE_URL = assertE2eTarget().databaseUrl; // Pin the fixture timezone independently of the deployment default. // Core-time and off-hours assertions use these explicit wall-clock times. process.env.TZ = 'Europe/Berlin'; diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 88d879e..f8cc0ef 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1,57 +1,119 @@ { "openapi": "3.0.0", "paths": { - "/api/auth/login": { - "post": { - "operationId": "AuthController_login", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginDto" + "/api/installation/hints": { + "get": { + "operationId": "InstallationController_hints", + "parameters": [ + { + "name": "from", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalHintsDto" + } } } } }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["installation"] + } + }, + "/api/installation": { + "get": { + "operationId": "InstallationController_get", + "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallationStateResponse" + } + } + } } }, - "tags": ["auth"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["installation"] } }, - "/api/auth/refresh": { - "post": { - "operationId": "AuthController_refresh", + "/api/installation/settings": { + "patch": { + "operationId": "InstallationController_settings", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RefreshDto" + "$ref": "#/components/schemas/UpdateSoloSettingsDto" } } } }, "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallationStateResponse" + } + } + } } }, - "tags": ["auth"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["installation"] } }, - "/api/auth/me": { - "get": { - "operationId": "AuthController_me", + "/api/installation/complete-setup": { + "post": { + "operationId": "InstallationController_complete", "parameters": [], "responses": { - "200": { - "description": "" + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallationStateResponse" + } + } + } } }, "security": [ @@ -59,26 +121,33 @@ "bearer": [] } ], - "tags": ["auth"] + "tags": ["installation"] } }, - "/api/auth/me/preferences": { - "patch": { - "operationId": "AuthController_updatePreferences", + "/api/installation/mode-preview": { + "post": { + "operationId": "InstallationController_preview", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdatePreferencesDto" + "$ref": "#/components/schemas/PreviewModeDto" } } } }, "responses": { - "200": { - "description": "" + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModePreviewResponse" + } + } + } } }, "security": [ @@ -86,44 +155,60 @@ "bearer": [] } ], - "tags": ["auth"] + "tags": ["installation"] } }, - "/api/health": { - "get": { - "operationId": "HealthController_check", + "/api/installation/mode": { + "post": { + "operationId": "InstallationController_mode", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeModeDto" + } + } + } + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HealthResponseDto" + "$ref": "#/components/schemas/InstallationStateResponse" } } } } }, - "tags": ["health"] - } - }, - "/api/employees": { - "get": { - "operationId": "EmployeesController_list", - "parameters": [ + "security": [ { - "name": "includeInactive", - "required": true, - "in": "query", - "schema": { - "type": "string" - } + "bearer": [] } ], + "tags": ["installation"] + } + }, + "/api/installation/days": { + "get": { + "operationId": "InstallationController_days", + "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonalDayResponse" + } + } + } + } } }, "security": [ @@ -131,24 +216,31 @@ "bearer": [] } ], - "tags": ["employees"] + "tags": ["installation"] }, "post": { - "operationId": "EmployeesController_create", + "operationId": "InstallationController_createDay", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" + "$ref": "#/components/schemas/PersonalDayDto" } } } }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalDayResponse" + } + } + } } }, "security": [ @@ -156,12 +248,12 @@ "bearer": [] } ], - "tags": ["employees"] + "tags": ["installation"] } }, - "/api/employees/{id}": { - "get": { - "operationId": "EmployeesController_get", + "/api/installation/days/{id}": { + "patch": { + "operationId": "InstallationController_updateDay", "parameters": [ { "name": "id", @@ -172,9 +264,26 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditPersonalDayDto" + } + } + } + }, "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalDayResponse" + } + } + } } }, "security": [ @@ -182,10 +291,10 @@ "bearer": [] } ], - "tags": ["employees"] + "tags": ["installation"] }, - "put": { - "operationId": "EmployeesController_update", + "delete": { + "operationId": "InstallationController_cancelDay", "parameters": [ { "name": "id", @@ -201,14 +310,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateEmployeeDto" + "$ref": "#/components/schemas/RevisionDto" } } } }, "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalDayResponse" + } + } + } } }, "security": [ @@ -216,23 +332,26 @@ "bearer": [] } ], - "tags": ["employees"] - }, - "delete": { - "operationId": "EmployeesController_deactivate", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "tags": ["installation"] + } + }, + "/api/installation/events": { + "get": { + "operationId": "InstallationController_events", + "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InstallationAuditResponse" + } + } + } + } } }, "security": [ @@ -240,12 +359,12 @@ "bearer": [] } ], - "tags": ["employees"] + "tags": ["installation"] } }, - "/api/employees/{id}/password": { - "post": { - "operationId": "EmployeesController_setPassword", + "/api/installation/days/{id}/audit": { + "get": { + "operationId": "InstallationController_dayAudit", "parameters": [ { "name": "id", @@ -256,45 +375,60 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetPasswordDto" + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InstallationAuditResponse" + } + } } } } }, - "responses": { - "204": { - "description": "" - } - }, "security": [ { "bearer": [] } ], - "tags": ["employees"] + "tags": ["installation"] } }, - "/api/employees/{id}/reactivate": { - "post": { - "operationId": "EmployeesController_reactivate", + "/api/installation/summary": { + "get": { + "operationId": "InstallationController_totals", "parameters": [ { - "name": "id", + "name": "from", "required": true, - "in": "path", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", "schema": { "type": "string" } } ], "responses": { - "201": { - "description": "" + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalSummaryResponse" + } + } + } } }, "security": [ @@ -302,36 +436,67 @@ "bearer": [] } ], - "tags": ["employees"] + "tags": ["installation"] } }, - "/api/work-schedules": { + "/api/customers": { "get": { - "operationId": "WorkSchedulesController_list", - "parameters": [], + "operationId": "CustomersController_list", + "parameters": [ + { + "name": "includeInactive", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomerDto" + } + } + } + } } }, - "tags": ["work-schedules"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["customers"] }, "post": { - "operationId": "WorkSchedulesController_create", + "operationId": "CustomersController_create", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertWorkScheduleDto" + "$ref": "#/components/schemas/UpsertCustomerDto" } } } }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerDto" + } + } + } } }, "security": [ @@ -339,12 +504,12 @@ "bearer": [] } ], - "tags": ["work-schedules"] + "tags": ["customers"] } }, - "/api/work-schedules/{id}": { + "/api/customers/{id}": { "get": { - "operationId": "WorkSchedulesController_get", + "operationId": "CustomersController_get", "parameters": [ { "name": "id", @@ -357,13 +522,25 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerDto" + } + } + } } }, - "tags": ["work-schedules"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["customers"] }, "put": { - "operationId": "WorkSchedulesController_update", + "operationId": "CustomersController_update", "parameters": [ { "name": "id", @@ -379,14 +556,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertWorkScheduleDto" + "$ref": "#/components/schemas/UpsertCustomerDto" } } } }, "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerDto" + } + } + } } }, "security": [ @@ -394,10 +578,10 @@ "bearer": [] } ], - "tags": ["work-schedules"] + "tags": ["customers"] }, "delete": { - "operationId": "WorkSchedulesController_remove", + "operationId": "CustomersController_remove", "parameters": [ { "name": "id", @@ -418,34 +602,25 @@ "bearer": [] } ], - "tags": ["work-schedules"] + "tags": ["customers"] } }, - "/api/work-schedules/{id}/assign": { - "post": { - "operationId": "WorkSchedulesController_assign", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "/api/auth/me": { + "patch": { + "operationId": "AuthController_profile", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssignToEmployeeDto" + "$ref": "#/components/schemas/UpdateOwnProfileDto" } } } }, "responses": { - "201": { + "200": { "description": "" } }, @@ -454,34 +629,40 @@ "bearer": [] } ], - "tags": ["work-schedules"] - } - }, - "/api/work-schedules/{id}/bulk-assign": { - "post": { - "operationId": "WorkSchedulesController_bulkAssign", - "parameters": [ + "tags": ["auth"] + }, + "get": { + "operationId": "AuthController_me", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "bearer": [] } ], + "tags": ["auth"] + } + }, + "/api/auth/password": { + "post": { + "operationId": "AuthController_password", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkAssignDto" + "$ref": "#/components/schemas/ChangePasswordDto" } } } }, "responses": { - "201": { + "200": { "description": "" } }, @@ -490,59 +671,67 @@ "bearer": [] } ], - "tags": ["work-schedules"] + "tags": ["auth"] } }, - "/api/projects": { - "get": { - "operationId": "ProjectsController_list", - "parameters": [ - { - "name": "includeInactive", - "required": true, - "in": "query", - "schema": { - "type": "string" + "/api/auth/login": { + "post": { + "operationId": "AuthController_login", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginDto" + } } } - ], + }, "responses": { "200": { "description": "" } }, - "tags": ["projects"] - }, + "tags": ["auth"] + } + }, + "/api/auth/refresh": { "post": { - "operationId": "ProjectsController_create", + "operationId": "AuthController_refresh", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertProjectDto" + "$ref": "#/components/schemas/RefreshDto" } } } }, "responses": { - "201": { + "200": { "description": "" } }, - "security": [ - { - "bearer": [] - } - ], - "tags": ["projects"] + "tags": ["auth"] } }, - "/api/projects/assignments": { - "get": { - "operationId": "ProjectsController_listAssignments", + "/api/auth/me/preferences": { + "patch": { + "operationId": "AuthController_updatePreferences", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePreferencesDto" + } + } + } + }, "responses": { "200": { "description": "" @@ -553,38 +742,36 @@ "bearer": [] } ], - "tags": ["projects"] + "tags": ["auth"] } }, - "/api/projects/bookable": { + "/api/health": { "get": { - "operationId": "ProjectsController_listBookable", - "parameters": [ - { - "name": "employeeId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], + "operationId": "HealthController_check", + "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponseDto" + } + } + } } }, - "tags": ["projects"] + "tags": ["health"] } }, - "/api/projects/{id}": { + "/api/employees": { "get": { - "operationId": "ProjectsController_get", + "operationId": "EmployeesController_list", "parameters": [ { - "name": "id", + "name": "includeInactive", "required": true, - "in": "path", + "in": "query", "schema": { "type": "string" } @@ -595,32 +782,28 @@ "description": "" } }, - "tags": ["projects"] - }, - "put": { - "operationId": "ProjectsController_update", - "parameters": [ + "security": [ { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "bearer": [] } ], + "tags": ["employees"] + }, + "post": { + "operationId": "EmployeesController_create", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertProjectDto" + "$ref": "#/components/schemas/CreateEmployeeDto" } } } }, "responses": { - "200": { + "201": { "description": "" } }, @@ -629,10 +812,12 @@ "bearer": [] } ], - "tags": ["projects"] - }, - "delete": { - "operationId": "ProjectsController_remove", + "tags": ["employees"] + } + }, + "/api/employees/{id}": { + "get": { + "operationId": "EmployeesController_get", "parameters": [ { "name": "id", @@ -644,7 +829,7 @@ } ], "responses": { - "204": { + "200": { "description": "" } }, @@ -653,12 +838,10 @@ "bearer": [] } ], - "tags": ["projects"] - } - }, - "/api/projects/{id}/report": { - "get": { - "operationId": "ProjectsController_report", + "tags": ["employees"] + }, + "put": { + "operationId": "EmployeesController_update", "parameters": [ { "name": "id", @@ -667,26 +850,80 @@ "schema": { "type": "string" } - }, + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEmployeeDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ { - "name": "from", + "bearer": [] + } + ], + "tags": ["employees"] + }, + "delete": { + "operationId": "EmployeesController_deactivate", + "parameters": [ + { + "name": "id", "required": true, - "in": "query", + "in": "path", "schema": { "type": "string" } - }, + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ { - "name": "to", + "bearer": [] + } + ], + "tags": ["employees"] + } + }, + "/api/employees/{id}/password": { + "post": { + "operationId": "EmployeesController_setPassword", + "parameters": [ + { + "name": "id", "required": true, - "in": "query", + "in": "path", "schema": { "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPasswordDto" + } + } + } + }, "responses": { - "200": { + "204": { "description": "" } }, @@ -695,12 +932,12 @@ "bearer": [] } ], - "tags": ["projects"] + "tags": ["employees"] } }, - "/api/projects/{id}/service-orders": { + "/api/employees/{id}/reactivate": { "post": { - "operationId": "ProjectsController_createServiceOrder", + "operationId": "EmployeesController_reactivate", "parameters": [ { "name": "id", @@ -711,12 +948,39 @@ } } ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["employees"] + } + }, + "/api/work-schedules": { + "get": { + "operationId": "WorkSchedulesController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["work-schedules"] + }, + "post": { + "operationId": "WorkSchedulesController_create", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertServiceOrderDto" + "$ref": "#/components/schemas/UpsertWorkScheduleDto" } } } @@ -731,12 +995,12 @@ "bearer": [] } ], - "tags": ["projects"] + "tags": ["work-schedules"] } }, - "/api/projects/{id}/service-orders/{orderId}": { - "put": { - "operationId": "ProjectsController_updateServiceOrder", + "/api/work-schedules/{id}": { + "get": { + "operationId": "WorkSchedulesController_get", "parameters": [ { "name": "id", @@ -745,9 +1009,20 @@ "schema": { "type": "string" } - }, + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["work-schedules"] + }, + "put": { + "operationId": "WorkSchedulesController_update", + "parameters": [ { - "name": "orderId", + "name": "id", "required": true, "in": "path", "schema": { @@ -760,7 +1035,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertServiceOrderDto" + "$ref": "#/components/schemas/UpsertWorkScheduleDto" } } } @@ -775,10 +1050,10 @@ "bearer": [] } ], - "tags": ["projects"] + "tags": ["work-schedules"] }, "delete": { - "operationId": "ProjectsController_removeServiceOrder", + "operationId": "WorkSchedulesController_remove", "parameters": [ { "name": "id", @@ -787,14 +1062,6 @@ "schema": { "type": "string" } - }, - { - "name": "orderId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } } ], "responses": { @@ -807,12 +1074,12 @@ "bearer": [] } ], - "tags": ["projects"] + "tags": ["work-schedules"] } }, - "/api/projects/{id}/assignments/{employeeId}": { - "put": { - "operationId": "ProjectsController_assign", + "/api/work-schedules/{id}/assign": { + "post": { + "operationId": "WorkSchedulesController_assign", "parameters": [ { "name": "id", @@ -821,18 +1088,20 @@ "schema": { "type": "string" } - }, - { - "name": "employeeId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignToEmployeeDto" + } + } + } + }, "responses": { - "204": { + "201": { "description": "" } }, @@ -841,10 +1110,12 @@ "bearer": [] } ], - "tags": ["projects"] - }, - "delete": { - "operationId": "ProjectsController_unassign", + "tags": ["work-schedules"] + } + }, + "/api/work-schedules/{id}/bulk-assign": { + "post": { + "operationId": "WorkSchedulesController_bulkAssign", "parameters": [ { "name": "id", @@ -853,18 +1124,20 @@ "schema": { "type": "string" } - }, - { - "name": "employeeId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkAssignDto" + } + } + } + }, "responses": { - "204": { + "201": { "description": "" } }, @@ -873,13 +1146,22 @@ "bearer": [] } ], - "tags": ["projects"] + "tags": ["work-schedules"] } }, - "/api/reports/working-times/employees": { + "/api/projects": { "get": { - "operationId": "ReportsController_workingTimeEmployees", - "parameters": [], + "operationId": "ProjectsController_list", + "parameters": [ + { + "name": "includeInactive", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "", @@ -888,71 +1170,152 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkingTimeReportEmployeeDto" + "$ref": "#/components/schemas/ProjectDto" } } } } } }, - "security": [ - { - "bearer": [] - } - ], - "tags": ["reports"] + "tags": ["projects"] + }, + "post": { + "operationId": "ProjectsController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertProjectDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["projects"] } }, - "/api/reports/working-times": { + "/api/projects/assignments": { "get": { - "operationId": "ReportsController_workingTimes", + "operationId": "ProjectsController_listAssignments", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["projects"] + } + }, + "/api/projects/bookable": { + "get": { + "operationId": "ProjectsController_listBookable", "parameters": [ { - "name": "from", + "name": "employeeId", "required": true, "in": "query", "schema": { - "format": "date", - "example": "2026-08-01", "type": "string" } - }, + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookableProjectDto" + } + } + } + } + } + }, + "tags": ["projects"] + } + }, + "/api/projects/{id}": { + "get": { + "operationId": "ProjectsController_get", + "parameters": [ { - "name": "to", + "name": "id", "required": true, - "in": "query", + "in": "path", "schema": { - "format": "date", - "example": "2026-08-31", "type": "string" } - }, - { - "name": "employeeId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDto" + } + } } - }, + } + }, + "tags": ["projects"] + }, + "put": { + "operationId": "ProjectsController_update", + "parameters": [ { - "name": "includeLocations", - "required": false, - "in": "query", + "name": "id", + "required": true, + "in": "path", "schema": { - "default": false, - "type": "boolean" + "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertProjectDto" + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkingTimeReportDto" + "$ref": "#/components/schemas/ProjectDto" } } } @@ -963,17 +1326,41 @@ "bearer": [] } ], - "tags": ["reports"] + "tags": ["projects"] + }, + "delete": { + "operationId": "ProjectsController_remove", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["projects"] } }, - "/api/timeentries": { + "/api/projects/{id}/report": { "get": { - "operationId": "TimeEntriesController_list", + "operationId": "ProjectsController_report", "parameters": [ { - "name": "employeeId", + "name": "id", "required": true, - "in": "query", + "in": "path", "schema": { "type": "string" } @@ -1005,26 +1392,42 @@ "bearer": [] } ], - "tags": ["time-entries"] + "tags": ["projects"] } }, - "/api/timeentries/clock-in": { + "/api/projects/{id}/service-orders": { "post": { - "operationId": "TimeEntriesController_clockIn", - "parameters": [], + "operationId": "ProjectsController_createServiceOrder", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClockInDto" + "$ref": "#/components/schemas/UpsertServiceOrderDto" } } } }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceOrderDto" + } + } + } } }, "security": [ @@ -1032,47 +1435,47 @@ "bearer": [] } ], - "tags": ["time-entries"] + "tags": ["projects"] } }, - "/api/timeentries/clock-out": { - "post": { - "operationId": "TimeEntriesController_clockOut", - "parameters": [], + "/api/projects/{id}/service-orders/{orderId}": { + "put": { + "operationId": "ProjectsController_updateServiceOrder", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "orderId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClockOutDto" + "$ref": "#/components/schemas/UpsertServiceOrderDto" } } } }, - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - } - ], - "tags": ["time-entries"] - } - }, - "/api/timeentries/daily-block/option": { - "get": { - "operationId": "TimeEntriesController_dailyBlockOption", - "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DailyBlockOptionDto" + "$ref": "#/components/schemas/ServiceOrderDto" } } } @@ -1083,25 +1486,30 @@ "bearer": [] } ], - "tags": ["time-entries"] - } - }, - "/api/timeentries/daily-block": { - "post": { - "operationId": "TimeEntriesController_dailyBlock", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDailyBlockDto" - } - } + "tags": ["projects"] + }, + "delete": { + "operationId": "ProjectsController_removeServiceOrder", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "orderId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } } - }, + ], "responses": { - "201": { + "204": { "description": "" } }, @@ -1110,25 +1518,32 @@ "bearer": [] } ], - "tags": ["time-entries"] + "tags": ["projects"] } }, - "/api/timeentries/book-project": { - "post": { - "operationId": "TimeEntriesController_bookProject", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookProjectRangeDto" - } + "/api/projects/{id}/assignments/{employeeId}": { + "put": { + "operationId": "ProjectsController_assign", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "employeeId", + "required": true, + "in": "path", + "schema": { + "type": "string" } } - }, + ], "responses": { - "201": { + "204": { "description": "" } }, @@ -1137,12 +1552,10 @@ "bearer": [] } ], - "tags": ["time-entries"] - } - }, - "/api/timeentries/{id}": { - "patch": { - "operationId": "TimeEntriesController_update", + "tags": ["projects"] + }, + "delete": { + "operationId": "ProjectsController_unassign", "parameters": [ { "name": "id", @@ -1151,20 +1564,18 @@ "schema": { "type": "string" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTimeEntryDto" - } + }, + { + "name": "employeeId", + "required": true, + "in": "path", + "schema": { + "type": "string" } } - }, + ], "responses": { - "200": { + "204": { "description": "" } }, @@ -1173,56 +1584,178 @@ "bearer": [] } ], - "tags": ["time-entries"] + "tags": ["projects"] } }, - "/api/timeentries/{id}/split": { - "post": { - "operationId": "TimeEntriesController_split", + "/api/reports/solo": { + "get": { + "operationId": "ReportsController_solo", "parameters": [ { - "name": "id", + "name": "from", "required": true, - "in": "path", + "in": "query", + "schema": { + "format": "date", + "example": "2026-09-01", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "format": "date", + "example": "2026-09-30", + "type": "string" + } + }, + { + "name": "customerId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "projectId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "serviceOrderId", + "required": false, + "in": "query", "schema": { + "format": "uuid", "type": "string" } + }, + { + "name": "billable", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "unassigned", + "required": false, + "in": "query", + "description": "True selects only time without a project.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SplitTimeEntryDto" + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SoloReportDto" + } } } } }, - "responses": { - "201": { - "description": "" - } - }, "security": [ { "bearer": [] } ], - "tags": ["time-entries"] + "tags": ["reports"] } }, - "/api/terminals/support-prompt": { + "/api/reports/solo.csv": { "get": { - "operationId": "TerminalsController_supportPrompt", - "parameters": [], + "operationId": "ReportsController_soloCsv", + "parameters": [ + { + "name": "from", + "required": true, + "in": "query", + "schema": { + "format": "date", + "example": "2026-09-01", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "format": "date", + "example": "2026-09-30", + "type": "string" + } + }, + { + "name": "customerId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "projectId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "serviceOrderId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "billable", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "unassigned", + "required": false, + "in": "query", + "description": "True selects only time without a project.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + } + ], "responses": { "200": { - "description": "", + "description": "UTF-8 BOM, semicolon-delimited CSV. Exact minutes; metadata rows state timezone and time definition.", "content": { - "application/json": { + "text/csv": { "schema": { - "$ref": "#/components/schemas/SupportPromptDto" + "type": "string" } } } @@ -1233,20 +1766,23 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["reports"] } }, - "/api/terminals/support-prompt/dismiss": { - "post": { - "operationId": "TerminalsController_dismissSupportPrompt", + "/api/reports/working-times/employees": { + "get": { + "operationId": "ReportsController_workingTimeEmployees", "parameters": [], "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SupportPromptDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkingTimeReportEmployeeDto" + } } } } @@ -1257,49 +1793,145 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["reports"] } }, - "/api/terminals/pair": { - "post": { - "operationId": "TerminalsController_pair", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PairTerminalDto" + "/api/reports/working-times": { + "get": { + "operationId": "ReportsController_workingTimes", + "parameters": [ + { + "name": "from", + "required": true, + "in": "query", + "schema": { + "format": "date", + "example": "2026-08-01", + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "format": "date", + "example": "2026-08-31", + "type": "string" + } + }, + { + "name": "employeeId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "includeLocations", + "required": false, + "in": "query", + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingTimeReportDto" + } } } } }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["reports"] + } + }, + "/api/timeentries": { + "get": { + "operationId": "TimeEntriesController_list", + "parameters": [ + { + "name": "employeeId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PairedTerminalDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryDto" + } } } } } }, - "tags": ["terminals"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["time-entries"] } }, - "/api/terminals/kiosk": { - "get": { - "operationId": "TerminalsController_kiosk", + "/api/timeentries/clock-in": { + "post": { + "operationId": "TimeEntriesController_clockIn", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockInDto" + } + } + } + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KioskStateDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1310,19 +1942,19 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["time-entries"] } }, - "/api/terminals/scan": { + "/api/timeentries/clock-out": { "post": { - "operationId": "TerminalsController_scan", + "operationId": "TimeEntriesController_clockOut", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanTerminalDto" + "$ref": "#/components/schemas/ClockOutDto" } } } @@ -1333,7 +1965,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanTerminalResultDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1344,23 +1976,29 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["time-entries"] } }, - "/api/terminals": { + "/api/timeentries/daily-block/option": { "get": { - "operationId": "TerminalsController_list", - "parameters": [], + "operationId": "TimeEntriesController_dailyBlockOption", + "parameters": [ + { + "name": "date", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TerminalDto" - } + "$ref": "#/components/schemas/DailyBlockOptionDto" } } } @@ -1371,17 +2009,19 @@ "bearer": [] } ], - "tags": ["terminals"] - }, + "tags": ["time-entries"] + } + }, + "/api/timeentries/daily-block": { "post": { - "operationId": "TerminalsController_create", + "operationId": "TimeEntriesController_dailyBlock", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTerminalDto" + "$ref": "#/components/schemas/CreateDailyBlockDto" } } } @@ -1392,7 +2032,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminalDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1403,29 +2043,64 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["time-entries"] } }, - "/api/terminals/{id}": { - "get": { - "operationId": "TerminalsController_get", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" + "/api/timeentries/book-project": { + "post": { + "operationId": "TimeEntriesController_bookProject", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookProjectRangeDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookProjectRangeResult" + } + } } } + }, + "security": [ + { + "bearer": [] + } ], + "tags": ["time-entries"] + } + }, + "/api/timeentries/manual": { + "post": { + "operationId": "TimeEntriesController_manual", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTimeEntryDto" + } + } + } + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminalDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1436,10 +2111,12 @@ "bearer": [] } ], - "tags": ["terminals"] - }, - "put": { - "operationId": "TerminalsController_update", + "tags": ["time-entries"] + } + }, + "/api/timeentries/{id}/correct": { + "patch": { + "operationId": "TimeEntriesController_correct", "parameters": [ { "name": "id", @@ -1455,7 +2132,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTerminalDto" + "$ref": "#/components/schemas/CorrectTimeEntryDto" } } } @@ -1466,7 +2143,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminalDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1477,10 +2154,12 @@ "bearer": [] } ], - "tags": ["terminals"] - }, - "delete": { - "operationId": "TerminalsController_deactivate", + "tags": ["time-entries"] + } + }, + "/api/timeentries/{id}/void": { + "post": { + "operationId": "TimeEntriesController_voidEntry", "parameters": [ { "name": "id", @@ -1491,13 +2170,23 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidTimeEntryDto" + } + } + } + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminalDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1508,12 +2197,12 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["time-entries"] } }, - "/api/terminals/{id}/permanent": { - "delete": { - "operationId": "TerminalsController_deletePermanently", + "/api/timeentries/{id}/switch-project": { + "post": { + "operationId": "TimeEntriesController_switchProject", "parameters": [ { "name": "id", @@ -1524,9 +2213,26 @@ } } ], - "responses": { - "204": { - "description": "Permanently deletes the terminal and kiosk-only data. Historical time entries remain." + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchProjectDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SplitTimeEntryResult" + } + } + } } }, "security": [ @@ -1534,12 +2240,12 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["time-entries"] } }, - "/api/terminals/{id}/pairing": { - "post": { - "operationId": "TerminalsController_createPairing", + "/api/timeentries/{id}/audit": { + "get": { + "operationId": "TimeEntriesController_audit", "parameters": [ { "name": "id", @@ -1551,12 +2257,15 @@ } ], "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PairingCodeDto" + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryAuditDto" + } } } } @@ -1567,12 +2276,12 @@ "bearer": [] } ], - "tags": ["terminals"] + "tags": ["time-entries"] } }, - "/api/terminals/{id}/devices/{deviceId}": { - "delete": { - "operationId": "TerminalsController_revokeDevice", + "/api/timeentries/{id}": { + "patch": { + "operationId": "TimeEntriesController_update", "parameters": [ { "name": "id", @@ -1581,23 +2290,25 @@ "schema": { "type": "string" } - }, - { - "name": "deviceId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTimeEntryDto" + } + } + } + }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TerminalDto" + "$ref": "#/components/schemas/TimeEntryDto" } } } @@ -1608,49 +2319,20 @@ "bearer": [] } ], - "tags": ["terminals"] - } - }, - "/api/employees/{employeeId}/leave-allowances": { - "get": { - "operationId": "LeaveAllowancesController_list", - "parameters": [ - { - "name": "employeeId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "tags": ["leave-allowances"] + "tags": ["time-entries"] } }, - "/api/employees/{employeeId}/leave-allowances/{year}": { - "put": { - "operationId": "LeaveAllowancesController_upsert", + "/api/timeentries/{id}/split": { + "post": { + "operationId": "TimeEntriesController_split", "parameters": [ { - "name": "employeeId", + "name": "id", "required": true, "in": "path", "schema": { "type": "string" } - }, - { - "name": "year", - "required": true, - "in": "path", - "schema": { - "type": "number" - } } ], "requestBody": { @@ -1658,14 +2340,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertLeaveAllowanceDto" + "$ref": "#/components/schemas/SplitTimeEntryDto" } } } }, "responses": { - "200": { - "description": "" + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SplitTimeEntryResult" + } + } + } } }, "security": [ @@ -1673,16 +2362,23 @@ "bearer": [] } ], - "tags": ["leave-allowances"] + "tags": ["time-entries"] } }, - "/api/admin/leave-allowances/expire-carryovers": { - "post": { - "operationId": "LeaveAllowancesAdminController_expireCarryOvers", + "/api/terminals/support-prompt": { + "get": { + "operationId": "TerminalsController_supportPrompt", "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportPromptDto" + } + } + } } }, "security": [ @@ -1690,127 +2386,137 @@ "bearer": [] } ], - "tags": ["admin"] + "tags": ["terminals"] } }, - "/api/cron/expire-carryovers": { + "/api/terminals/support-prompt/dismiss": { "post": { - "operationId": "CronCarryOverController_expireCarryOvers", + "operationId": "TerminalsController_dismissSupportPrompt", "parameters": [], "responses": { - "200": { - "description": "" + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SupportPromptDto" + } + } + } } }, - "tags": ["cron"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["terminals"] } }, - "/api/accounts/{employeeId}": { - "get": { - "operationId": "AccountsController_account", - "parameters": [ - { - "name": "employeeId", - "required": true, - "in": "path", - "schema": { - "type": "string" + "/api/terminals/pair": { + "post": { + "operationId": "TerminalsController_pair", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairTerminalDto" + } } } - ], + }, "responses": { - "200": { - "description": "" + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairedTerminalDto" + } + } + } } }, - "tags": ["accounts"] + "tags": ["terminals"] } }, - "/api/accounts/{employeeId}/vacation": { + "/api/terminals/kiosk": { "get": { - "operationId": "AccountsController_vacationBalance", - "parameters": [ - { - "name": "employeeId", - "required": true, - "in": "path", - "schema": { - "type": "string" + "operationId": "TerminalsController_kiosk", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KioskStateDto" + } + } } - }, + } + }, + "security": [ { - "name": "year", - "required": true, - "in": "query", - "schema": { - "type": "number" - } + "bearer": [] } ], + "tags": ["terminals"] + } + }, + "/api/terminals/scan": { + "post": { + "operationId": "TerminalsController_scan", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanTerminalDto" + } + } + } + }, "responses": { - "200": { - "description": "" + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanTerminalResultDto" + } + } + } } }, - "tags": ["accounts"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["terminals"] } }, - "/api/requests": { + "/api/terminals": { "get": { - "operationId": "RequestsController_list", - "parameters": [ - { - "name": "employeeId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "status", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "workflowState", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "approverId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "currentApproverId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "substituteId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], + "operationId": "TerminalsController_list", + "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TerminalDto" + } + } + } + } } }, "security": [ @@ -1818,24 +2524,31 @@ "bearer": [] } ], - "tags": ["requests"] + "tags": ["terminals"] }, "post": { - "operationId": "RequestsController_create", + "operationId": "TerminalsController_create", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateRequestDto" + "$ref": "#/components/schemas/CreateTerminalDto" } } } }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalDto" + } + } + } } }, "security": [ @@ -1843,12 +2556,12 @@ "bearer": [] } ], - "tags": ["requests"] + "tags": ["terminals"] } }, - "/api/requests/{id}": { + "/api/terminals/{id}": { "get": { - "operationId": "RequestsController_get", + "operationId": "TerminalsController_get", "parameters": [ { "name": "id", @@ -1861,7 +2574,14 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalDto" + } + } + } } }, "security": [ @@ -1869,12 +2589,10 @@ "bearer": [] } ], - "tags": ["requests"] - } - }, - "/api/requests/{id}/events": { - "get": { - "operationId": "RequestsController_events", + "tags": ["terminals"] + }, + "put": { + "operationId": "TerminalsController_update", "parameters": [ { "name": "id", @@ -1885,36 +2603,26 @@ } } ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - } - ], - "tags": ["requests"] - } - }, - "/api/requests/vacation": { - "post": { - "operationId": "RequestsController_createVacation", - "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateVacationDto" + "$ref": "#/components/schemas/UpdateTerminalDto" } } } }, "responses": { - "201": { - "description": "" + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalDto" + } + } + } } }, "security": [ @@ -1922,12 +2630,10 @@ "bearer": [] } ], - "tags": ["requests"] - } - }, - "/api/requests/{id}/approve": { - "post": { - "operationId": "RequestsController_approve", + "tags": ["terminals"] + }, + "delete": { + "operationId": "TerminalsController_deactivate", "parameters": [ { "name": "id", @@ -1938,32 +2644,29 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransitionDto" + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalDto" + } } } } }, - "responses": { - "201": { - "description": "" - } - }, "security": [ { "bearer": [] } ], - "tags": ["requests"] + "tags": ["terminals"] } }, - "/api/requests/{id}/reject": { - "post": { - "operationId": "RequestsController_reject", + "/api/terminals/{id}/permanent": { + "delete": { + "operationId": "TerminalsController_deletePermanently", "parameters": [ { "name": "id", @@ -1974,19 +2677,9 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransitionDto" - } - } - } - }, "responses": { - "201": { - "description": "" + "204": { + "description": "Permanently deletes the terminal and kiosk-only data. Historical time entries remain." } }, "security": [ @@ -1994,12 +2687,12 @@ "bearer": [] } ], - "tags": ["requests"] + "tags": ["terminals"] } }, - "/api/requests/{id}/manager-approve": { + "/api/terminals/{id}/pairing": { "post": { - "operationId": "RequestsController_managerApprove", + "operationId": "TerminalsController_createPairing", "parameters": [ { "name": "id", @@ -2010,19 +2703,16 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ManagerApproveDto" - } - } - } - }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingCodeDto" + } + } + } } }, "security": [ @@ -2030,12 +2720,12 @@ "bearer": [] } ], - "tags": ["requests"] + "tags": ["terminals"] } }, - "/api/requests/{id}/manager-reject": { - "post": { - "operationId": "RequestsController_managerReject", + "/api/terminals/{id}/devices/{deviceId}": { + "delete": { + "operationId": "TerminalsController_revokeDevice", "parameters": [ { "name": "id", @@ -2044,37 +2734,42 @@ "schema": { "type": "string" } + }, + { + "name": "deviceId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransitionDto" + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalDto" + } } } } }, - "responses": { - "201": { - "description": "" - } - }, "security": [ { "bearer": [] } ], - "tags": ["requests"] + "tags": ["terminals"] } }, - "/api/requests/{id}/hr-confirm": { - "post": { - "operationId": "RequestsController_hrConfirm", + "/api/employees/{employeeId}/leave-allowances": { + "get": { + "operationId": "LeaveAllowancesController_list", "parameters": [ { - "name": "id", + "name": "employeeId", "required": true, "in": "path", "schema": { @@ -2082,18 +2777,47 @@ } } ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["leave-allowances"] + } + }, + "/api/employees/{employeeId}/leave-allowances/{year}": { + "put": { + "operationId": "LeaveAllowancesController_upsert", + "parameters": [ + { + "name": "employeeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "year", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransitionDto" + "$ref": "#/components/schemas/UpsertLeaveAllowanceDto" } } } }, "responses": { - "201": { + "200": { "description": "" } }, @@ -2102,28 +2826,162 @@ "bearer": [] } ], - "tags": ["requests"] + "tags": ["leave-allowances"] } }, - "/api/requests/{id}/hr-reject": { + "/api/admin/leave-allowances/expire-carryovers": { "post": { - "operationId": "RequestsController_hrReject", + "operationId": "LeaveAllowancesAdminController_expireCarryOvers", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["admin"] + } + }, + "/api/cron/expire-carryovers": { + "post": { + "operationId": "CronCarryOverController_expireCarryOvers", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["cron"] + } + }, + "/api/accounts/{employeeId}": { + "get": { + "operationId": "AccountsController_account", "parameters": [ { - "name": "id", + "name": "employeeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["accounts"] + } + }, + "/api/accounts/{employeeId}/vacation": { + "get": { + "operationId": "AccountsController_vacationBalance", + "parameters": [ + { + "name": "employeeId", "required": true, "in": "path", "schema": { "type": "string" } + }, + { + "name": "year", + "required": true, + "in": "query", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["accounts"] + } + }, + "/api/requests": { + "get": { + "operationId": "RequestsController_list", + "parameters": [ + { + "name": "employeeId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "workflowState", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "approverId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "currentApproverId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "substituteId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + }, + "post": { + "operationId": "RequestsController_create", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransitionWithRequiredNoteDto" + "$ref": "#/components/schemas/CreateRequestDto" } } } @@ -2141,9 +2999,9 @@ "tags": ["requests"] } }, - "/api/requests/{id}/substitute/accept": { - "post": { - "operationId": "RequestsController_substituteAccept", + "/api/requests/{id}": { + "get": { + "operationId": "RequestsController_get", "parameters": [ { "name": "id", @@ -2154,18 +3012,8 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TransitionDto" - } - } - } - }, "responses": { - "201": { + "200": { "description": "" } }, @@ -2177,9 +3025,9 @@ "tags": ["requests"] } }, - "/api/requests/{id}/substitute/decline": { - "post": { - "operationId": "RequestsController_substituteDecline", + "/api/requests/{id}/events": { + "get": { + "operationId": "RequestsController_events", "parameters": [ { "name": "id", @@ -2190,12 +3038,29 @@ } } ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + } + }, + "/api/requests/vacation": { + "post": { + "operationId": "RequestsController_createVacation", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransitionWithRequiredNoteDto" + "$ref": "#/components/schemas/CreateVacationDto" } } } @@ -2213,9 +3078,9 @@ "tags": ["requests"] } }, - "/api/requests/{id}/return": { + "/api/requests/{id}/approve": { "post": { - "operationId": "RequestsController_returnForRevision", + "operationId": "RequestsController_approve", "parameters": [ { "name": "id", @@ -2231,7 +3096,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TransitionWithRequiredNoteDto" + "$ref": "#/components/schemas/TransitionDto" } } } @@ -2249,9 +3114,9 @@ "tags": ["requests"] } }, - "/api/requests/{id}/cancel": { + "/api/requests/{id}/reject": { "post": { - "operationId": "RequestsController_cancel", + "operationId": "RequestsController_reject", "parameters": [ { "name": "id", @@ -2285,16 +3150,25 @@ "tags": ["requests"] } }, - "/api/requests/bulk-approve": { + "/api/requests/{id}/manager-approve": { "post": { - "operationId": "RequestsController_bulkApprove", - "parameters": [], + "operationId": "RequestsController_managerApprove", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkApproveDto" + "$ref": "#/components/schemas/ManagerApproveDto" } } } @@ -2312,16 +3186,25 @@ "tags": ["requests"] } }, - "/api/requests/bulk-reject": { + "/api/requests/{id}/manager-reject": { "post": { - "operationId": "RequestsController_bulkReject", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkRejectDto" + "operationId": "RequestsController_managerReject", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionDto" } } } @@ -2339,37 +3222,31 @@ "tags": ["requests"] } }, - "/api/absences": { - "get": { - "operationId": "AbsencesController_list", + "/api/requests/{id}/hr-confirm": { + "post": { + "operationId": "RequestsController_hrConfirm", "parameters": [ { - "name": "employeeId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "from", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "to", + "name": "id", "required": true, - "in": "query", + "in": "path", "schema": { "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionDto" + } + } + } + }, "responses": { - "200": { + "201": { "description": "" } }, @@ -2378,17 +3255,28 @@ "bearer": [] } ], - "tags": ["absences"] - }, + "tags": ["requests"] + } + }, + "/api/requests/{id}/hr-reject": { "post": { - "operationId": "AbsencesController_create", - "parameters": [], + "operationId": "RequestsController_hrReject", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateAbsenceDto" + "$ref": "#/components/schemas/TransitionWithRequiredNoteDto" } } } @@ -2403,12 +3291,12 @@ "bearer": [] } ], - "tags": ["absences"] + "tags": ["requests"] } }, - "/api/absences/{id}": { - "put": { - "operationId": "AbsencesController_update", + "/api/requests/{id}/substitute/accept": { + "post": { + "operationId": "RequestsController_substituteAccept", "parameters": [ { "name": "id", @@ -2424,13 +3312,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateAbsenceDto" + "$ref": "#/components/schemas/TransitionDto" } } } }, "responses": { - "200": { + "201": { "description": "" } }, @@ -2439,10 +3327,12 @@ "bearer": [] } ], - "tags": ["absences"] - }, - "delete": { - "operationId": "AbsencesController_remove", + "tags": ["requests"] + } + }, + "/api/requests/{id}/substitute/decline": { + "post": { + "operationId": "RequestsController_substituteDecline", "parameters": [ { "name": "id", @@ -2453,8 +3343,18 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionWithRequiredNoteDto" + } + } + } + }, "responses": { - "204": { + "201": { "description": "" } }, @@ -2463,15 +3363,15 @@ "bearer": [] } ], - "tags": ["absences"] + "tags": ["requests"] } }, - "/api/requests/{requestId}/attachments": { + "/api/requests/{id}/return": { "post": { - "operationId": "AttachmentsController_upload", + "operationId": "RequestsController_returnForRevision", "parameters": [ { - "name": "requestId", + "name": "id", "required": true, "in": "path", "schema": { @@ -2482,16 +3382,9 @@ "requestBody": { "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { - "type": "object", - "properties": { - "file": { - "type": "string", - "format": "binary" - } - }, - "required": ["file"] + "$ref": "#/components/schemas/TransitionWithRequiredNoteDto" } } } @@ -2506,13 +3399,15 @@ "bearer": [] } ], - "tags": ["attachments"] - }, - "get": { - "operationId": "AttachmentsController_list", + "tags": ["requests"] + } + }, + "/api/requests/{id}/cancel": { + "post": { + "operationId": "RequestsController_cancel", "parameters": [ { - "name": "requestId", + "name": "id", "required": true, "in": "path", "schema": { @@ -2520,8 +3415,18 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionDto" + } + } + } + }, "responses": { - "200": { + "201": { "description": "" } }, @@ -2530,24 +3435,25 @@ "bearer": [] } ], - "tags": ["attachments"] + "tags": ["requests"] } }, - "/api/attachments/{id}": { - "get": { - "operationId": "AttachmentsController_download", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" + "/api/requests/bulk-approve": { + "post": { + "operationId": "RequestsController_bulkApprove", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkApproveDto" + } } } - ], + }, "responses": { - "200": { + "201": { "description": "" } }, @@ -2556,22 +3462,25 @@ "bearer": [] } ], - "tags": ["attachments"] - }, - "delete": { - "operationId": "AttachmentsController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" + "tags": ["requests"] + } + }, + "/api/requests/bulk-reject": { + "post": { + "operationId": "RequestsController_bulkReject", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkRejectDto" + } } } - ], + }, "responses": { - "204": { + "201": { "description": "" } }, @@ -2580,12 +3489,12 @@ "bearer": [] } ], - "tags": ["attachments"] + "tags": ["requests"] } }, - "/api/violations": { + "/api/absences": { "get": { - "operationId": "ViolationsController_list", + "operationId": "AbsencesController_list", "parameters": [ { "name": "employeeId", @@ -2617,15 +3526,259 @@ "description": "" } }, - "tags": ["violations"] + "security": [ + { + "bearer": [] + } + ], + "tags": ["absences"] + }, + "post": { + "operationId": "AbsencesController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAbsenceDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["absences"] } }, - "/api/erp/timeentries": { - "get": { - "operationId": "ErpExportController_list", + "/api/absences/{id}": { + "put": { + "operationId": "AbsencesController_update", "parameters": [ { - "name": "from", + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAbsenceDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["absences"] + }, + "delete": { + "operationId": "AbsencesController_remove", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["absences"] + } + }, + "/api/requests/{requestId}/attachments": { + "post": { + "operationId": "AttachmentsController_upload", + "parameters": [ + { + "name": "requestId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["attachments"] + }, + "get": { + "operationId": "AttachmentsController_list", + "parameters": [ + { + "name": "requestId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["attachments"] + } + }, + "/api/attachments/{id}": { + "get": { + "operationId": "AttachmentsController_download", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["attachments"] + }, + "delete": { + "operationId": "AttachmentsController_remove", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["attachments"] + } + }, + "/api/violations": { + "get": { + "operationId": "ViolationsController_list", + "parameters": [ + { + "name": "employeeId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["violations"] + } + }, + "/api/erp/timeentries": { + "get": { + "operationId": "ErpExportController_list", + "parameters": [ + { + "name": "from", "required": true, "in": "query", "schema": { @@ -2669,7 +3822,7 @@ "info": { "title": "OpenClockwork API", "description": "Self-hostable working-time tracker — REST + WebSocket surface.", - "version": "1.4.0", + "version": "2.0.0", "contact": {} }, "tags": [], @@ -2683,550 +3836,2125 @@ } }, "schemas": { - "LoginDto": { + "PersonalHintDto": { "type": "object", "properties": { - "email": { - "type": "string", - "example": "hannah.roth@openclockwork.test" + "date": { + "type": "string" }, - "password": { + "kind": { "type": "string", - "example": "openclockwork" + "enum": [ + "BeforeFrame", + "AfterFrame", + "LateArrival", + "EarlyDeparture", + "MidDayGap" + ] + }, + "boundary": { + "type": "string" + }, + "deltaMinutes": { + "type": "number" + }, + "windowLabel": { + "type": "string" } }, - "required": ["email", "password"] + "required": ["date", "kind", "boundary", "deltaMinutes"] }, - "RefreshDto": { + "PersonalHintsDto": { "type": "object", "properties": { - "refreshToken": { - "type": "string", - "description": "A refresh token previously returned from /auth/login or /auth/refresh." + "enabled": { + "type": "boolean" + }, + "hints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonalHintDto" + } } }, - "required": ["refreshToken"] - }, - "ThemePreference": { - "type": "string", - "enum": ["Light", "Dark", "System"], - "description": "Light / Dark / System. System follows the OS color-scheme media query in the browser." + "required": ["enabled", "hints"] }, - "UpdatePreferencesDto": { + "SoloCapabilitiesResponse": { "type": "object", "properties": { - "themePreference": { - "description": "Light / Dark / System. System follows the OS color-scheme media query in the browser.", - "allOf": [ - { - "$ref": "#/components/schemas/ThemePreference" - } - ] + "isOwner": { + "type": "boolean" + }, + "solo": { + "type": "boolean" + }, + "targets": { + "type": "boolean" + }, + "leave": { + "type": "boolean" + }, + "coreTimeHints": { + "type": "boolean" + }, + "dailyBlock": { + "type": "boolean" + }, + "gps": { + "type": "boolean" } }, - "required": ["themePreference"] + "required": [ + "isOwner", + "solo", + "targets", + "leave", + "coreTimeHints", + "dailyBlock", + "gps" + ] }, - "HealthResponseDto": { + "SoloBreakRuleResponse": { "type": "object", "properties": { - "status": { - "type": "string", - "example": "ok" - }, - "service": { - "type": "string", - "example": "openclockwork-api" - }, - "version": { - "type": "string", - "example": "1.4.0" + "afterMinutes": { + "type": "number" }, - "utcTimestamp": { - "type": "string", - "format": "date-time" + "breakMinutes": { + "type": "number" } }, - "required": ["status", "service", "version", "utcTimestamp"] + "required": ["afterMinutes", "breakMinutes"] }, - "CreateEmployeeDto": { + "SoloCoreWindowResponse": { "type": "object", "properties": { - "personalNo": { - "type": "string", - "example": "1001", - "maxLength": 40 - }, - "firstName": { - "type": "string", - "example": "Anna", - "maxLength": 120 + "start": { + "type": "string" }, - "lastName": { - "type": "string", - "example": "Mueller", - "maxLength": 120 + "end": { + "type": "string" }, - "email": { - "type": "string", - "example": "anna.mueller@openclockwork.test", - "maxLength": 200 + "weekdays": { + "type": "number" }, - "password": { + "label": { + "type": "object", + "nullable": true + } + }, + "required": ["start", "end", "weekdays"] + }, + "SoloPolicyResponse": { + "type": "object", + "properties": { + "id": { "type": "string", - "minLength": 8, - "maxLength": 120, - "description": "Initial password — bcrypt-hashed on the server." + "nullable": true }, - "role": { + "effectiveFrom": { "type": "string", - "enum": ["Employee", "Manager", "HRAdmin"], - "example": "Employee" + "nullable": true, + "format": "date" }, - "timeModel": { - "type": "string", - "enum": [ - "Teilzeit", - "Vollzeit", - "Vertrauensarbeitszeit", - "Gleitzeit" - ], - "example": "Vollzeit" + "targetEnabled": { + "type": "boolean" }, - "weeklyHours": { + "weeklyTargetMinutes": { "type": "number", - "example": 40, - "minimum": 0 + "nullable": true + }, + "workingDays": { + "type": "number" + }, + "leaveEnabled": { + "type": "boolean" }, "annualLeaveDays": { - "type": "number", - "example": 30, - "minimum": 0 + "type": "number" }, - "startDate": { + "carryOverDays": { + "type": "number" + }, + "carryOverExpiresOn": { "type": "string", - "example": "2026-04-01", - "description": "ISO date when the employee starts; target working hours are counted from here." + "nullable": true, + "format": "date" }, - "overtimeOpeningBalanceMinutes": { - "type": "number", - "example": 0, - "description": "One-time overtime carry-over in minutes (signed)." + "leaveAdjustmentDays": { + "type": "number" }, - "bundesland": { + "leaveAdjustmentReason": { "type": "string", - "enum": [ - "BW", - "BY", - "BE", - "BB", - "HB", - "HH", - "HE", - "MV", - "NI", - "NW", - "RP", - "SL", - "SN", - "ST", - "SH", - "TH" - ], - "deprecated": true, - "description": "Legacy alias for a DE-XX holidayCalendar; use holidayCalendar for new clients." + "nullable": true + }, + "leaveAllowanceYear": { + "type": "number" }, "holidayCalendar": { - "type": "string", - "enum": [ - "NONE", - "DE-BW", - "DE-BY", - "DE-BE", - "DE-BB", - "DE-HB", - "DE-HH", - "DE-HE", - "DE-MV", - "DE-NI", - "DE-NW", - "DE-RP", - "DE-SL", - "DE-SN", - "DE-ST", - "DE-SH", - "DE-TH" - ], - "default": "NONE", - "description": "Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country." + "type": "string" }, "holidayDates": { - "example": ["2026-07-01"], - "description": "Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.", "type": "array", "items": { "type": "string" } }, - "allowDailyBlockBooking": { - "type": "boolean", - "default": false, - "description": "Allow one self-approved fixed-duration block on a configured working day." + "breakRules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SoloBreakRuleResponse" + } }, - "managerId": { - "type": "object", - "nullable": true, - "format": "uuid" + "coreTimeHintsEnabled": { + "type": "boolean" }, - "workScheduleId": { - "type": "object", - "nullable": true, - "format": "uuid" + "dailyBlockEnabled": { + "type": "boolean" + }, + "gpsEnabled": { + "type": "boolean" + }, + "frameStart": { + "type": "string" + }, + "frameEnd": { + "type": "string" + }, + "coreTimes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SoloCoreWindowResponse" + } } }, "required": [ - "personalNo", - "firstName", - "lastName", - "email", - "password", - "role", - "timeModel", - "weeklyHours", + "id", + "effectiveFrom", + "targetEnabled", + "weeklyTargetMinutes", + "workingDays", + "leaveEnabled", "annualLeaveDays", - "startDate" + "carryOverDays", + "carryOverExpiresOn", + "leaveAdjustmentDays", + "leaveAdjustmentReason", + "leaveAllowanceYear", + "holidayCalendar", + "holidayDates", + "breakRules", + "coreTimeHintsEnabled", + "dailyBlockEnabled", + "gpsEnabled", + "frameStart", + "frameEnd", + "coreTimes" ] }, - "UpdateEmployeeDto": { + "InstallationStateResponse": { "type": "object", "properties": { - "personalNo": { + "mode": { "type": "string", - "maxLength": 40 + "enum": ["Team", "Solo"] }, - "firstName": { + "ownerEmployeeId": { "type": "string", - "maxLength": 120 + "nullable": true }, - "lastName": { - "type": "string", - "maxLength": 120 + "setupCompleted": { + "type": "boolean" }, - "email": { - "type": "string", - "maxLength": 200 + "revision": { + "type": "number" }, - "role": { - "type": "string", - "enum": ["Employee", "Manager", "HRAdmin"] + "timeZone": { + "type": "string" }, - "timeModel": { - "type": "string", - "enum": [ - "Teilzeit", - "Vollzeit", - "Vertrauensarbeitszeit", - "Gleitzeit" - ] + "capabilities": { + "$ref": "#/components/schemas/SoloCapabilitiesResponse" }, - "weeklyHours": { - "type": "number", - "minimum": 0 + "policy": { + "$ref": "#/components/schemas/SoloPolicyResponse" }, - "annualLeaveDays": { - "type": "number", - "minimum": 0 - }, - "startDate": { - "type": "string", - "example": "2026-04-01" - }, - "overtimeOpeningBalanceMinutes": { - "type": "number" - }, - "bundesland": { - "type": "string", - "enum": [ - "BW", - "BY", - "BE", - "BB", - "HB", - "HH", - "HE", - "MV", - "NI", - "NW", - "RP", - "SL", - "SN", - "ST", - "SH", - "TH" - ], - "deprecated": true - }, - "holidayCalendar": { - "type": "string", - "enum": [ - "NONE", - "DE-BW", - "DE-BY", - "DE-BE", - "DE-BB", - "DE-HB", - "DE-HH", - "DE-HE", - "DE-MV", - "DE-NI", - "DE-NW", - "DE-RP", - "DE-SL", - "DE-SN", - "DE-ST", - "DE-SH", - "DE-TH" - ], - "default": "NONE", - "description": "Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country." - }, - "holidayDates": { - "example": ["2026-07-01"], - "description": "Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.", - "type": "array", - "items": { - "type": "string" - } - }, - "allowDailyBlockBooking": { - "type": "boolean", - "description": "Allow one self-approved fixed-duration block on a configured working day." - }, - "managerId": { - "type": "object", - "nullable": true, - "format": "uuid" - }, - "workScheduleId": { - "type": "object", - "nullable": true, - "format": "uuid" - }, - "isActive": { - "type": "boolean" - } - } - }, - "SetPasswordDto": { - "type": "object", - "properties": { - "password": { - "type": "string", - "minLength": 8, - "maxLength": 120 - } - }, - "required": ["password"] - }, - "BreakRuleDto": { - "type": "object", - "properties": { - "afterMinutes": { - "type": "number", - "minimum": 0, - "maximum": 1440, - "description": "Inclusive attendance threshold in minutes." + "futurePolicies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SoloPolicyResponse" + } + } + }, + "required": [ + "mode", + "ownerEmployeeId", + "setupCompleted", + "revision", + "timeZone", + "capabilities", + "policy", + "futurePolicies" + ] + }, + "SoloBreakRuleDto": { + "type": "object", + "properties": { + "afterMinutes": { + "type": "number" }, "breakMinutes": { - "type": "number", - "minimum": 0, - "maximum": 1440, - "description": "Total deduction in minutes; must not exceed afterMinutes." + "type": "number" } }, "required": ["afterMinutes", "breakMinutes"] }, - "CoreTimeWindowDto": { + "SoloCoreWindowDto": { "type": "object", "properties": { - "label": { - "type": "object", - "nullable": true, - "maxLength": 120, - "example": "Vormittag" - }, "start": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", - "example": "10:00" + "type": "string" }, "end": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", - "example": "11:00" + "type": "string" }, "weekdays": { - "type": "number", - "minimum": 0, - "maximum": 127, - "description": "Bitmask: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64. Mo–Fr = 31." + "type": "number" + }, + "label": { + "type": "string" } }, "required": ["start", "end", "weekdays"] }, - "UpsertWorkScheduleDto": { + "UpdateSoloSettingsDto": { "type": "object", "properties": { - "name": { + "revision": { + "type": "number" + }, + "effectiveFrom": { "type": "string", - "maxLength": 120 + "example": "2026-09-08" }, - "description": { + "targetEnabled": { + "type": "boolean" + }, + "weeklyTargetMinutes": { "type": "object", - "nullable": true, - "maxLength": 2000 + "nullable": true }, - "frameStart": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", - "example": "07:00" + "workingDays": { + "type": "number" }, - "frameEnd": { - "type": "string", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", - "example": "23:00" + "leaveEnabled": { + "type": "boolean" }, - "isDefault": { - "type": "boolean", - "default": false + "annualLeaveDays": { + "type": "number" }, - "workingDays": { - "type": "number", - "minimum": 0, - "maximum": 127, - "default": 31, - "description": "Working-day bitmask. Mon=1, Tue=2, …, Sun=64. Mo–Fr = 31, Mo–Sa = 63." + "carryOverDays": { + "type": "number" + }, + "carryOverExpiresOn": { + "type": "object", + "nullable": true + }, + "leaveAdjustmentDays": { + "type": "number" + }, + "leaveAdjustmentReason": { + "type": "object", + "nullable": true + }, + "leaveAllowanceYear": { + "type": "number" + }, + "holidayCalendar": { + "type": "string" + }, + "holidayDates": { + "type": "array", + "items": { + "type": "string" + } }, "breakRules": { - "default": [], - "description": "Automatic break deductions. Empty means none. The largest matching total applies; not a legal compliance guarantee. Omission on update preserves the policy.", "type": "array", "items": { - "$ref": "#/components/schemas/BreakRuleDto" + "$ref": "#/components/schemas/SoloBreakRuleDto" } }, + "coreTimeHintsEnabled": { + "type": "boolean" + }, + "frameStart": { + "type": "string" + }, + "frameEnd": { + "type": "string" + }, "coreTimes": { "type": "array", "items": { - "$ref": "#/components/schemas/CoreTimeWindowDto" + "$ref": "#/components/schemas/SoloCoreWindowDto" } + }, + "dailyBlockEnabled": { + "type": "boolean" + }, + "gpsEnabled": { + "type": "boolean" } }, - "required": ["name", "frameStart", "frameEnd", "coreTimes"] + "required": [ + "revision", + "effectiveFrom", + "targetEnabled", + "workingDays", + "leaveEnabled", + "annualLeaveDays", + "holidayCalendar", + "holidayDates", + "breakRules", + "coreTimeHintsEnabled", + "dailyBlockEnabled", + "gpsEnabled" + ] }, - "AssignToEmployeeDto": { + "PreviewModeDto": { "type": "object", "properties": { - "employeeId": { + "mode": { "type": "string", - "format": "uuid" + "enum": ["Team", "Solo"] } }, - "required": ["employeeId"] + "required": ["mode"] }, - "BulkAssignDto": { + "ModePreviewResponse": { "type": "object", "properties": { - "timeModel": { - "type": "string", - "enum": [ - "Teilzeit", - "Vollzeit", - "Vertrauensarbeitszeit", - "Gleitzeit" - ] + "allowed": { + "type": "boolean" }, - "overrideExisting": { - "type": "boolean", - "default": false + "blockers": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["timeModel"] + "required": ["allowed", "blockers"] }, - "UpsertProjectDto": { + "ChangeModeDto": { "type": "object", "properties": { - "code": { + "mode": { "type": "string", - "maxLength": 40, - "example": "PRJ-001" - }, - "name": { - "type": "string", - "maxLength": 200 - }, - "description": { - "type": "object", - "nullable": true, - "maxLength": 2000 - }, - "isActive": { - "type": "boolean", - "default": true + "enum": ["Team", "Solo"] }, - "planHours": { - "type": "object", - "nullable": true, - "minimum": 0 + "revision": { + "type": "number" } }, - "required": ["code", "name"] + "required": ["mode", "revision"] }, - "UpsertServiceOrderDto": { + "PersonalDayResponse": { "type": "object", "properties": { - "orderNo": { + "id": { "type": "string", - "maxLength": 60, - "example": "SA-2026-001" + "format": "uuid" }, - "title": { + "kind": { "type": "string", - "maxLength": 200 + "enum": ["Free", "Vacation", "Sickness", "Training"] }, - "isActive": { - "type": "boolean", + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "note": { + "type": "string", + "nullable": true + }, + "halfDayStart": { + "type": "boolean" + }, + "halfDayEnd": { + "type": "boolean" + }, + "revision": { + "type": "number" + }, + "cancelledAt": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "kind", + "from", + "to", + "note", + "halfDayStart", + "halfDayEnd", + "revision", + "cancelledAt" + ] + }, + "PersonalDayDto": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["Free", "Vacation", "Sickness", "Training"] + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "note": { + "type": "object", + "nullable": true + }, + "halfDayStart": { + "type": "boolean" + }, + "halfDayEnd": { + "type": "boolean" + } + }, + "required": ["kind", "from", "to"] + }, + "EditPersonalDayDto": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["Free", "Vacation", "Sickness", "Training"] + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "note": { + "type": "object", + "nullable": true + }, + "halfDayStart": { + "type": "boolean" + }, + "halfDayEnd": { + "type": "boolean" + }, + "revision": { + "type": "number" + } + }, + "required": ["kind", "from", "to", "revision"] + }, + "RevisionDto": { + "type": "object", + "properties": { + "revision": { + "type": "number" + } + }, + "required": ["revision"] + }, + "InstallationAuditResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "actorId": { + "type": "string", + "nullable": true + }, + "action": { + "type": "string" + }, + "before": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "after": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "occurredAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id", "actorId", "action", "before", "after", "occurredAt"] + }, + "PersonalSummaryResponse": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "timeZone": { + "type": "string" + }, + "targetEnabled": { + "type": "boolean" + }, + "leaveEnabled": { + "type": "boolean" + }, + "actualMinutes": { + "type": "number" + }, + "targetActualMinutes": { + "type": "number", + "nullable": true, + "description": "Actual net minutes on dates with an enabled Solo target." + }, + "targetMinutes": { + "type": "number", + "nullable": true + }, + "overtimeMinutes": { + "type": "number", + "nullable": true + }, + "vacationDaysTotal": { + "type": "number", + "nullable": true + }, + "vacationDaysUsed": { + "type": "number", + "nullable": true + }, + "vacationDaysRemaining": { + "type": "number", + "nullable": true + }, + "vacationAllowanceYear": { + "type": "number" + }, + "vacationDaysCarryOver": { + "type": "number", + "nullable": true + }, + "vacationDaysCarryOverUsed": { + "type": "number", + "nullable": true + }, + "vacationDaysCarryOverExpired": { + "type": "number", + "nullable": true + }, + "vacationDaysAdjustment": { + "type": "number", + "nullable": true + } + }, + "required": [ + "from", + "to", + "timeZone", + "targetEnabled", + "leaveEnabled", + "actualMinutes", + "targetActualMinutes", + "targetMinutes", + "overtimeMinutes", + "vacationDaysTotal", + "vacationDaysUsed", + "vacationDaysRemaining", + "vacationAllowanceYear", + "vacationDaysCarryOver", + "vacationDaysCarryOverUsed", + "vacationDaysCarryOverExpired", + "vacationDaysAdjustment" + ] + }, + "CustomerDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "code": { + "type": "string", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + }, + "projectCount": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "code", + "note", + "isActive", + "projectCount", + "createdAt", + "updatedAt" + ] + }, + "UpsertCustomerDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "code": { + "type": "object", + "nullable": true, + "maxLength": 40 + }, + "note": { + "type": "object", + "nullable": true, + "maxLength": 2000 + }, + "isActive": { + "type": "boolean", "default": true + } + }, + "required": ["name"] + }, + "UpdateOwnProfileDto": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": ["firstName", "lastName", "email"] + }, + "ChangePasswordDto": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + }, + "required": ["currentPassword", "newPassword"] + }, + "LoginDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "hannah.roth@openclockwork.test" + }, + "password": { + "type": "string", + "example": "openclockwork" + } + }, + "required": ["email", "password"] + }, + "RefreshDto": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string", + "description": "A refresh token previously returned from /auth/login or /auth/refresh." + } + }, + "required": ["refreshToken"] + }, + "ThemePreference": { + "type": "string", + "enum": ["Light", "Dark", "System"], + "description": "Light / Dark / System. System follows the OS color-scheme media query in the browser." + }, + "UpdatePreferencesDto": { + "type": "object", + "properties": { + "themePreference": { + "description": "Light / Dark / System. System follows the OS color-scheme media query in the browser.", + "allOf": [ + { + "$ref": "#/components/schemas/ThemePreference" + } + ] + } + }, + "required": ["themePreference"] + }, + "HealthResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "service": { + "type": "string", + "example": "openclockwork-api" + }, + "version": { + "type": "string", + "example": "2.0.0" + }, + "utcTimestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": ["status", "service", "version", "utcTimestamp"] + }, + "CreateEmployeeDto": { + "type": "object", + "properties": { + "personalNo": { + "type": "string", + "example": "1001", + "maxLength": 40 + }, + "firstName": { + "type": "string", + "example": "Anna", + "maxLength": 120 + }, + "lastName": { + "type": "string", + "example": "Mueller", + "maxLength": 120 + }, + "email": { + "type": "string", + "example": "anna.mueller@openclockwork.test", + "maxLength": 200 + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 120, + "description": "Initial password — bcrypt-hashed on the server." + }, + "role": { + "type": "string", + "enum": ["Employee", "Manager", "HRAdmin"], + "example": "Employee" + }, + "timeModel": { + "type": "string", + "enum": [ + "Teilzeit", + "Vollzeit", + "Vertrauensarbeitszeit", + "Gleitzeit" + ], + "example": "Vollzeit" + }, + "weeklyHours": { + "type": "number", + "example": 40, + "minimum": 0 + }, + "annualLeaveDays": { + "type": "number", + "example": 30, + "minimum": 0 + }, + "startDate": { + "type": "string", + "example": "2026-04-01", + "description": "ISO date when the employee starts; target working hours are counted from here." + }, + "overtimeOpeningBalanceMinutes": { + "type": "number", + "example": 0, + "description": "One-time overtime carry-over in minutes (signed)." + }, + "bundesland": { + "type": "string", + "enum": [ + "BW", + "BY", + "BE", + "BB", + "HB", + "HH", + "HE", + "MV", + "NI", + "NW", + "RP", + "SL", + "SN", + "ST", + "SH", + "TH" + ], + "deprecated": true, + "description": "Legacy alias for a DE-XX holidayCalendar; use holidayCalendar for new clients." + }, + "holidayCalendar": { + "type": "string", + "enum": [ + "NONE", + "DE-BW", + "DE-BY", + "DE-BE", + "DE-BB", + "DE-HB", + "DE-HH", + "DE-HE", + "DE-MV", + "DE-NI", + "DE-NW", + "DE-RP", + "DE-SL", + "DE-SN", + "DE-ST", + "DE-SH", + "DE-TH" + ], + "default": "NONE", + "description": "Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country." + }, + "holidayDates": { + "example": ["2026-07-01"], + "description": "Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.", + "type": "array", + "items": { + "type": "string" + } + }, + "allowDailyBlockBooking": { + "type": "boolean", + "default": false, + "description": "Allow one self-approved fixed-duration block on a configured working day." + }, + "managerId": { + "type": "object", + "nullable": true, + "format": "uuid" + }, + "workScheduleId": { + "type": "object", + "nullable": true, + "format": "uuid" + } + }, + "required": [ + "personalNo", + "firstName", + "lastName", + "email", + "password", + "role", + "timeModel", + "weeklyHours", + "annualLeaveDays", + "startDate" + ] + }, + "UpdateEmployeeDto": { + "type": "object", + "properties": { + "personalNo": { + "type": "string", + "maxLength": 40 + }, + "firstName": { + "type": "string", + "maxLength": 120 + }, + "lastName": { + "type": "string", + "maxLength": 120 + }, + "email": { + "type": "string", + "maxLength": 200 + }, + "role": { + "type": "string", + "enum": ["Employee", "Manager", "HRAdmin"] + }, + "timeModel": { + "type": "string", + "enum": [ + "Teilzeit", + "Vollzeit", + "Vertrauensarbeitszeit", + "Gleitzeit" + ] + }, + "weeklyHours": { + "type": "number", + "minimum": 0 + }, + "annualLeaveDays": { + "type": "number", + "minimum": 0 + }, + "startDate": { + "type": "string", + "example": "2026-04-01" + }, + "overtimeOpeningBalanceMinutes": { + "type": "number" + }, + "bundesland": { + "type": "string", + "enum": [ + "BW", + "BY", + "BE", + "BB", + "HB", + "HH", + "HE", + "MV", + "NI", + "NW", + "RP", + "SL", + "SN", + "ST", + "SH", + "TH" + ], + "deprecated": true + }, + "holidayCalendar": { + "type": "string", + "enum": [ + "NONE", + "DE-BW", + "DE-BY", + "DE-BE", + "DE-BB", + "DE-HB", + "DE-HH", + "DE-HE", + "DE-MV", + "DE-NI", + "DE-NW", + "DE-RP", + "DE-SL", + "DE-SN", + "DE-ST", + "DE-SH", + "DE-TH" + ], + "default": "NONE", + "description": "Optional holiday preset. NONE makes no public-holiday assumptions; explicit holidayDates work in every country." + }, + "holidayDates": { + "example": ["2026-07-01"], + "description": "Explicit non-working dates in YYYY-MM-DD format; supplement the selected preset.", + "type": "array", + "items": { + "type": "string" + } + }, + "allowDailyBlockBooking": { + "type": "boolean", + "description": "Allow one self-approved fixed-duration block on a configured working day." + }, + "managerId": { + "type": "object", + "nullable": true, + "format": "uuid" + }, + "workScheduleId": { + "type": "object", + "nullable": true, + "format": "uuid" + }, + "isActive": { + "type": "boolean" + } + } + }, + "SetPasswordDto": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 8, + "maxLength": 120 + } + }, + "required": ["password"] + }, + "BreakRuleDto": { + "type": "object", + "properties": { + "afterMinutes": { + "type": "number", + "minimum": 0, + "maximum": 1440, + "description": "Inclusive attendance threshold in minutes." + }, + "breakMinutes": { + "type": "number", + "minimum": 0, + "maximum": 1440, + "description": "Total deduction in minutes; must not exceed afterMinutes." + } + }, + "required": ["afterMinutes", "breakMinutes"] + }, + "CoreTimeWindowDto": { + "type": "object", + "properties": { + "label": { + "type": "object", + "nullable": true, + "maxLength": 120, + "example": "Vormittag" + }, + "start": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "example": "10:00" + }, + "end": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "example": "11:00" + }, + "weekdays": { + "type": "number", + "minimum": 0, + "maximum": 127, + "description": "Bitmask: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64. Mo–Fr = 31." + } + }, + "required": ["start", "end", "weekdays"] + }, + "UpsertWorkScheduleDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 120 + }, + "description": { + "type": "object", + "nullable": true, + "maxLength": 2000 + }, + "frameStart": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "example": "07:00" + }, + "frameEnd": { + "type": "string", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "example": "23:00" + }, + "isDefault": { + "type": "boolean", + "default": false + }, + "workingDays": { + "type": "number", + "minimum": 0, + "maximum": 127, + "default": 31, + "description": "Working-day bitmask. Mon=1, Tue=2, …, Sun=64. Mo–Fr = 31, Mo–Sa = 63." + }, + "breakRules": { + "default": [], + "description": "Automatic break deductions. Empty means none. The largest matching total applies; not a legal compliance guarantee. Omission on update preserves the policy.", + "type": "array", + "items": { + "$ref": "#/components/schemas/BreakRuleDto" + } + }, + "coreTimes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoreTimeWindowDto" + } + } + }, + "required": ["name", "frameStart", "frameEnd", "coreTimes"] + }, + "AssignToEmployeeDto": { + "type": "object", + "properties": { + "employeeId": { + "type": "string", + "format": "uuid" + } + }, + "required": ["employeeId"] + }, + "BulkAssignDto": { + "type": "object", + "properties": { + "timeModel": { + "type": "string", + "enum": [ + "Teilzeit", + "Vollzeit", + "Vertrauensarbeitszeit", + "Gleitzeit" + ] + }, + "overrideExisting": { + "type": "boolean", + "default": false + } + }, + "required": ["timeModel"] + }, + "ServiceOrderDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "orderNo": { + "type": "string" + }, + "title": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "planHours": { + "type": "number", + "nullable": true + }, + "bookedMinutes": { + "type": "number" + }, + "defaultBillable": { + "type": "boolean", + "nullable": true + }, + "bookedNetMinutes": { + "type": "number" + } + }, + "required": [ + "id", + "projectId", + "orderNo", + "title", + "isActive", + "planHours", + "bookedMinutes", + "defaultBillable" + ] + }, + "ProjectDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + }, + "planHours": { + "type": "number", + "nullable": true + }, + "customerId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "customerName": { + "type": "string", + "nullable": true + }, + "defaultBillable": { + "type": "boolean" + }, + "bookedNetMinutes": { + "type": "number" + }, + "bookedMinutes": { + "type": "number" + }, + "serviceOrders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceOrderDto" + } + }, + "assignedEmployeeCount": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "code", + "name", + "description", + "isActive", + "planHours", + "customerId", + "customerName", + "defaultBillable", + "bookedMinutes", + "serviceOrders", + "assignedEmployeeCount", + "updatedAt" + ] + }, + "BookableServiceOrderDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "orderNo": { + "type": "string" + }, + "title": { + "type": "string" + }, + "defaultBillable": { + "type": "boolean", + "nullable": true + } + }, + "required": ["id", "orderNo", "title", "defaultBillable"] + }, + "BookableProjectDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "customerId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "customerName": { + "type": "string", + "nullable": true + }, + "defaultBillable": { + "type": "boolean" + }, + "serviceOrders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookableServiceOrderDto" + } + } + }, + "required": [ + "id", + "code", + "name", + "customerId", + "customerName", + "defaultBillable", + "serviceOrders" + ] + }, + "UpsertProjectDto": { + "type": "object", + "properties": { + "code": { + "type": "string", + "maxLength": 40, + "example": "PRJ-001" + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "object", + "nullable": true, + "maxLength": 2000 + }, + "isActive": { + "type": "boolean", + "default": true + }, + "planHours": { + "type": "object", + "nullable": true, + "minimum": 0 + }, + "customerId": { + "type": "object", + "format": "uuid", + "nullable": true + }, + "defaultBillable": { + "type": "boolean", + "default": false + } + }, + "required": ["code", "name"] + }, + "UpsertServiceOrderDto": { + "type": "object", + "properties": { + "orderNo": { + "type": "string", + "maxLength": 60, + "example": "SA-2026-001" + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "isActive": { + "type": "boolean", + "default": true + }, + "planHours": { + "type": "object", + "nullable": true, + "minimum": 0 + }, + "defaultBillable": { + "type": "object", + "nullable": true, + "description": "Null inherits the project default." + } + }, + "required": ["orderNo", "title"] + }, + "SoloReportRowDto": { + "type": "object", + "properties": { + "grossMinutes": { + "type": "number", + "minimum": 0 + }, + "breakMinutes": { + "type": "number", + "minimum": 0 + }, + "netMinutes": { + "type": "number", + "minimum": 0 + }, + "billableNetMinutes": { + "type": "number", + "minimum": 0 + }, + "id": { + "type": "string", + "format": "uuid" + }, + "date": { + "type": "string", + "format": "date" + }, + "clockIn": { + "type": "string", + "format": "date-time" + }, + "clockOut": { + "type": "string", + "format": "date-time" + }, + "customerId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "customerName": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "projectCode": { + "type": "string", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "serviceOrderId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "orderNo": { + "type": "string", + "nullable": true + }, + "orderTitle": { + "type": "string", + "nullable": true + }, + "activity": { + "type": "string", + "nullable": true + }, + "billable": { + "type": "boolean" + } + }, + "required": [ + "grossMinutes", + "breakMinutes", + "netMinutes", + "billableNetMinutes", + "id", + "date", + "clockIn", + "clockOut", + "customerId", + "customerName", + "projectId", + "projectCode", + "projectName", + "serviceOrderId", + "orderNo", + "orderTitle", + "activity", + "billable" + ] + }, + "SoloReportTotalsDto": { + "type": "object", + "properties": { + "grossMinutes": { + "type": "number", + "minimum": 0 + }, + "breakMinutes": { + "type": "number", + "minimum": 0 + }, + "netMinutes": { + "type": "number", + "minimum": 0 + }, + "billableNetMinutes": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "grossMinutes", + "breakMinutes", + "netMinutes", + "billableNetMinutes" + ] + }, + "SoloReportDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "timeZone": { + "type": "string" + }, + "timeDefinition": { + "type": "string", + "enum": ["net_working_time"] + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SoloReportRowDto" + } + }, + "totals": { + "$ref": "#/components/schemas/SoloReportTotalsDto" + }, + "openTimerCount": { + "type": "number", + "minimum": 0, + "description": "Matching open timers, excluded from totals and customer statements." + } + }, + "required": [ + "from", + "to", + "timeZone", + "timeDefinition", + "rows", + "totals", + "openTimerCount" + ] + }, + "WorkingTimeReportEmployeeDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + }, + "required": ["id", "firstName", "lastName"] + }, + "WorkingTimeReportLocationDto": { + "type": "object", + "properties": { + "label": { + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "nullable": true, + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "nullable": true, + "minimum": -180, + "maximum": 180 + }, + "accuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + } + }, + "required": ["label", "latitude", "longitude", "accuracyMeters"] + }, + "WorkingTimeReportRowDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "employeeId": { + "type": "string", + "format": "uuid" + }, + "employeeName": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date" + }, + "clockIn": { + "type": "string", + "format": "date-time" + }, + "clockOut": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": ["Open", "Pending", "Approved"] + }, + "grossMinutes": { + "type": "number", + "minimum": 0 + }, + "breakMinutes": { + "type": "number", + "minimum": 0 + }, + "netMinutes": { + "type": "number", + "minimum": 0 + }, + "clockInLocation": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/WorkingTimeReportLocationDto" + } + ] + }, + "clockOutLocation": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/WorkingTimeReportLocationDto" + } + ] + } + }, + "required": [ + "id", + "employeeId", + "employeeName", + "date", + "clockIn", + "clockOut", + "status", + "grossMinutes", + "breakMinutes", + "netMinutes" + ] + }, + "WorkingTimeReportTotalsDto": { + "type": "object", + "properties": { + "grossMinutes": { + "type": "number", + "minimum": 0 + }, + "breakMinutes": { + "type": "number", + "minimum": 0 + }, + "netMinutes": { + "type": "number", + "minimum": 0 + } + }, + "required": ["grossMinutes", "breakMinutes", "netMinutes"] + }, + "WorkingTimeReportDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkingTimeReportRowDto" + } + }, + "totals": { + "$ref": "#/components/schemas/WorkingTimeReportTotalsDto" + } + }, + "required": ["from", "to", "rows", "totals"] + }, + "TimeSummaryDto": { + "type": "object", + "properties": { + "grossMinutes": { + "type": "number" + }, + "breakMinutes": { + "type": "number" + }, + "netMinutes": { + "type": "number" + } + }, + "required": ["grossMinutes", "breakMinutes", "netMinutes"] + }, + "TimeEntryDto": { + "type": "object", + "properties": { + "note": { + "type": "string", + "nullable": true + }, + "revision": { + "type": "number", + "minimum": 0 + }, + "billable": { + "type": "boolean" + }, + "voidedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "captureGroupId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "approvalMode": { + "type": "string", + "enum": ["Solo", "Team"], + "nullable": true + }, + "id": { + "type": "string", + "format": "uuid" + }, + "employeeId": { + "type": "string", + "format": "uuid" + }, + "clockIn": { + "type": "string", + "format": "date-time" + }, + "clockOut": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "source": { + "type": "string", + "enum": ["Manual", "Pwa", "Terminal", "Erp", "DailyBlock"] + }, + "status": { + "type": "string", + "enum": ["Open", "Pending", "Approved", "Rejected"] + }, + "requiresApproval": { + "type": "boolean" + }, + "latitude": { + "type": "number", + "nullable": true, + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "nullable": true, + "minimum": -180, + "maximum": 180 + }, + "accuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "terminalDistanceMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "terminalRadiusMeters": { + "type": "number", + "nullable": true, + "minimum": 10, + "maximum": 1000, + "description": "Terminal geofence radius that was valid when clock-in was accepted." + }, + "terminalMaxAccuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 5, + "maximum": 500, + "description": "Terminal maximum GPS accuracy that was valid when clock-in was accepted." + }, + "positionTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "clockOutLatitude": { + "type": "number", + "nullable": true, + "minimum": -90, + "maximum": 90 + }, + "clockOutLongitude": { + "type": "number", + "nullable": true, + "minimum": -180, + "maximum": 180 + }, + "clockOutAccuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "clockOutTerminalDistanceMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "clockOutTerminalRadiusMeters": { + "type": "number", + "nullable": true, + "minimum": 10, + "maximum": 1000, + "description": "Terminal geofence radius that was valid when clock-out was accepted." }, - "planHours": { - "type": "object", + "clockOutTerminalMaxAccuracyMeters": { + "type": "number", "nullable": true, - "minimum": 0 + "minimum": 5, + "maximum": 500, + "description": "Terminal maximum GPS accuracy that was valid when clock-out was accepted." + }, + "clockOutPositionTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "terminalId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "clockOutTerminalId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "clockInChallengeId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "clockOutChallengeId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "projectId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "projectCode": { + "type": "string", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "serviceOrderId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "serviceOrderNo": { + "type": "string", + "nullable": true + }, + "serviceOrderTitle": { + "type": "string", + "nullable": true + }, + "activity": { + "type": "string", + "nullable": true + }, + "summary": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/TimeSummaryDto" + } + ] } }, - "required": ["orderNo", "title"] + "required": [ + "note", + "revision", + "billable", + "voidedAt", + "captureGroupId", + "approvalMode", + "id", + "employeeId", + "clockIn", + "clockOut", + "source", + "status", + "requiresApproval", + "latitude", + "longitude", + "accuracyMeters", + "terminalDistanceMeters", + "terminalRadiusMeters", + "terminalMaxAccuracyMeters", + "positionTimestamp", + "clockOutLatitude", + "clockOutLongitude", + "clockOutAccuracyMeters", + "clockOutTerminalDistanceMeters", + "clockOutTerminalRadiusMeters", + "clockOutTerminalMaxAccuracyMeters", + "clockOutPositionTimestamp", + "terminalId", + "clockOutTerminalId", + "clockInChallengeId", + "clockOutChallengeId", + "projectId", + "projectCode", + "projectName", + "serviceOrderId", + "serviceOrderNo", + "serviceOrderTitle", + "activity", + "summary" + ] }, - "WorkingTimeReportEmployeeDto": { + "ClockInDto": { "type": "object", "properties": { - "id": { + "note": { "type": "string", - "format": "uuid" + "nullable": true, + "maxLength": 2000 }, - "firstName": { - "type": "string" + "billable": { + "type": "boolean" }, - "lastName": { - "type": "string" + "employeeId": { + "type": "string", + "format": "uuid", + "deprecated": true, + "description": "Ignored. The employee identity is always taken from the JWT." + }, + "latitude": { + "type": "number", + "nullable": true, + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "nullable": true, + "minimum": -180, + "maximum": 180 + }, + "accuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "projectId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "serviceOrderId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "activity": { + "type": "string", + "nullable": true, + "maxLength": 500 } - }, - "required": ["id", "firstName", "lastName"] + } }, - "WorkingTimeReportLocationDto": { + "ClockOutDto": { "type": "object", "properties": { - "label": { + "id": { "type": "string", - "nullable": true + "format": "uuid", + "description": "Required in Solo mode to identify the timer being stopped." + }, + "revision": { + "type": "number", + "minimum": 0, + "description": "Required in Solo mode." + }, + "employeeId": { + "type": "string", + "format": "uuid", + "deprecated": true, + "description": "Ignored. The employee identity is always taken from the JWT." }, "latitude": { "type": "number", @@ -3240,132 +5968,160 @@ "minimum": -180, "maximum": 180 }, - "accuracyMeters": { + "accuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 0 + } + } + }, + "DailyBlockOptionDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "dailyNetMinutes": { + "type": "number", + "description": "Contractual net working minutes for one configured workday." + }, + "grossMinutes": { + "type": "number", + "description": "Attendance minutes including the configured automatic break deduction." + }, + "breakMinutes": { + "type": "number" + }, + "workdayCount": { + "type": "number" + } + }, + "required": [ + "enabled", + "dailyNetMinutes", + "grossMinutes", + "breakMinutes", + "workdayCount" + ] + }, + "CreateDailyBlockDto": { + "type": "object", + "properties": { + "note": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "billable": { + "type": "boolean" + }, + "date": { + "type": "string", + "example": "2026-08-13", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "start": { + "type": "string", + "example": "08:00", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + }, + "projectId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "serviceOrderId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "activity": { + "type": "string", + "nullable": true, + "maxLength": 500 + } + }, + "required": ["date", "start"] + }, + "EntryRevisionDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "revision": { "type": "number", - "nullable": true, "minimum": 0 } }, - "required": ["label", "latitude", "longitude", "accuracyMeters"] + "required": ["id", "revision"] }, - "WorkingTimeReportRowDto": { + "BookProjectRangeDto": { "type": "object", "properties": { - "id": { - "type": "string", - "format": "uuid" + "revisions": { + "description": "Required for Solo. Exact IDs/revisions of all entries intersecting the range.", + "type": "array", + "items": { + "$ref": "#/components/schemas/EntryRevisionDto" + } + }, + "billable": { + "type": "boolean" }, "employeeId": { "type": "string", "format": "uuid" }, - "employeeName": { - "type": "string" - }, - "date": { - "type": "string", - "format": "date" - }, - "clockIn": { + "from": { "type": "string", "format": "date-time" }, - "clockOut": { + "to": { "type": "string", "format": "date-time" }, - "status": { + "projectId": { "type": "string", - "enum": ["Open", "Pending", "Approved"] - }, - "grossMinutes": { - "type": "number", - "minimum": 0 - }, - "breakMinutes": { - "type": "number", - "minimum": 0 - }, - "netMinutes": { - "type": "number", - "minimum": 0 + "format": "uuid" }, - "clockInLocation": { - "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/WorkingTimeReportLocationDto" - } - ] + "serviceOrderId": { + "type": "string", + "format": "uuid", + "nullable": true }, - "clockOutLocation": { + "activity": { + "type": "string", "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/WorkingTimeReportLocationDto" - } - ] - } - }, - "required": [ - "id", - "employeeId", - "employeeName", - "date", - "clockIn", - "clockOut", - "status", - "grossMinutes", - "breakMinutes", - "netMinutes" - ] - }, - "WorkingTimeReportTotalsDto": { - "type": "object", - "properties": { - "grossMinutes": { - "type": "number", - "minimum": 0 - }, - "breakMinutes": { - "type": "number", - "minimum": 0 - }, - "netMinutes": { - "type": "number", - "minimum": 0 + "maxLength": 500 } }, - "required": ["grossMinutes", "breakMinutes", "netMinutes"] + "required": ["employeeId", "from", "to", "projectId"] }, - "WorkingTimeReportDto": { + "BookProjectRangeResult": { "type": "object", "properties": { - "from": { - "type": "string", - "format": "date" - }, - "to": { - "type": "string", - "format": "date" - }, - "rows": { + "entries": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkingTimeReportRowDto" + "$ref": "#/components/schemas/TimeEntryDto" } - }, - "totals": { - "$ref": "#/components/schemas/WorkingTimeReportTotalsDto" } }, - "required": ["from", "to", "rows", "totals"] + "required": ["entries"] }, - "ClockInDto": { + "ManualTimeEntryDto": { "type": "object", "properties": { + "note": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "billable": { + "type": "boolean" + }, "employeeId": { "type": "string", "format": "uuid", @@ -3403,12 +6159,31 @@ "type": "string", "nullable": true, "maxLength": 500 + }, + "clockIn": { + "type": "string", + "format": "date-time", + "description": "Absolute instant with UTC Z or explicit offset." + }, + "clockOut": { + "type": "string", + "format": "date-time", + "description": "Absolute instant with UTC Z or explicit offset." } - } + }, + "required": ["clockIn", "clockOut"] }, - "ClockOutDto": { + "CorrectTimeEntryDto": { "type": "object", "properties": { + "note": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "billable": { + "type": "boolean" + }, "employeeId": { "type": "string", "format": "uuid", @@ -3431,50 +6206,90 @@ "type": "number", "nullable": true, "minimum": 0 + }, + "projectId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "serviceOrderId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "activity": { + "type": "string", + "nullable": true, + "maxLength": 500 + }, + "clockIn": { + "type": "string", + "format": "date-time", + "description": "Absolute instant with UTC Z or explicit offset." + }, + "clockOut": { + "type": "string", + "format": "date-time", + "description": "Absolute instant with UTC Z or explicit offset." + }, + "revision": { + "type": "number", + "minimum": 0 + }, + "reason": { + "type": "string", + "maxLength": 500 } - } + }, + "required": ["clockIn", "clockOut", "revision", "reason"] }, - "DailyBlockOptionDto": { + "VoidTimeEntryDto": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "dailyNetMinutes": { - "type": "number", - "description": "Contractual net working minutes for one configured workday." - }, - "grossMinutes": { + "revision": { "type": "number", - "description": "Attendance minutes including the configured automatic break deduction." - }, - "breakMinutes": { - "type": "number" + "minimum": 0 }, - "workdayCount": { - "type": "number" + "reason": { + "type": "string", + "maxLength": 500 } }, - "required": [ - "enabled", - "dailyNetMinutes", - "grossMinutes", - "breakMinutes", - "workdayCount" - ] + "required": ["revision", "reason"] }, - "CreateDailyBlockDto": { + "SwitchProjectDto": { "type": "object", "properties": { - "date": { + "note": { "type": "string", - "example": "2026-08-13", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + "nullable": true, + "maxLength": 2000 }, - "start": { + "billable": { + "type": "boolean" + }, + "employeeId": { "type": "string", - "example": "08:00", - "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" + "format": "uuid", + "deprecated": true, + "description": "Ignored. The employee identity is always taken from the JWT." + }, + "latitude": { + "type": "number", + "nullable": true, + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "nullable": true, + "minimum": -180, + "maximum": 180 + }, + "accuracyMeters": { + "type": "number", + "nullable": true, + "minimum": 0 }, "projectId": { "type": "string", @@ -3490,45 +6305,89 @@ "type": "string", "nullable": true, "maxLength": 500 + }, + "revision": { + "type": "number", + "minimum": 0 } }, - "required": ["date", "start"] + "required": ["revision"] }, - "BookProjectRangeDto": { + "SplitTimeEntryResult": { "type": "object", "properties": { - "employeeId": { + "first": { + "$ref": "#/components/schemas/TimeEntryDto" + }, + "second": { + "$ref": "#/components/schemas/TimeEntryDto" + } + }, + "required": ["first", "second"] + }, + "TimeEntryAuditDto": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, - "from": { + "timeEntryId": { "type": "string", - "format": "date-time" + "format": "uuid" }, - "to": { + "actorId": { "type": "string", - "format": "date-time" + "format": "uuid", + "nullable": true + }, + "action": { + "type": "string" + }, + "before": { + "type": "object", + "nullable": true }, - "projectId": { - "type": "string", - "format": "uuid" + "after": { + "type": "object", + "nullable": true }, - "serviceOrderId": { + "reason": { "type": "string", - "format": "uuid", "nullable": true }, - "activity": { + "occurredAt": { "type": "string", - "nullable": true, - "maxLength": 500 + "format": "date-time" } }, - "required": ["employeeId", "from", "to", "projectId"] + "required": [ + "id", + "timeEntryId", + "actorId", + "action", + "before", + "after", + "reason", + "occurredAt" + ] }, "UpdateTimeEntryDto": { "type": "object", "properties": { + "note": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "revision": { + "type": "number", + "minimum": 0, + "description": "Required for Solo entries; prevents lost updates." + }, + "billable": { + "type": "boolean" + }, "projectId": { "type": "string", "format": "uuid", @@ -3549,6 +6408,14 @@ "SplitTimeEntryDto": { "type": "object", "properties": { + "revision": { + "type": "number", + "minimum": 0, + "description": "Required for Solo entries." + }, + "billable": { + "type": "boolean" + }, "at": { "type": "string", "format": "date-time" @@ -3702,230 +6569,6 @@ }, "required": ["qrPayload"] }, - "TimeSummaryDto": { - "type": "object", - "properties": { - "grossMinutes": { - "type": "number" - }, - "breakMinutes": { - "type": "number" - }, - "netMinutes": { - "type": "number" - } - }, - "required": ["grossMinutes", "breakMinutes", "netMinutes"] - }, - "TimeEntryDto": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "employeeId": { - "type": "string", - "format": "uuid" - }, - "clockIn": { - "type": "string", - "format": "date-time" - }, - "clockOut": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "source": { - "type": "string", - "enum": ["Manual", "Pwa", "Terminal", "Erp", "DailyBlock"] - }, - "status": { - "type": "string", - "enum": ["Open", "Pending", "Approved", "Rejected"] - }, - "requiresApproval": { - "type": "boolean" - }, - "latitude": { - "type": "number", - "nullable": true, - "minimum": -90, - "maximum": 90 - }, - "longitude": { - "type": "number", - "nullable": true, - "minimum": -180, - "maximum": 180 - }, - "accuracyMeters": { - "type": "number", - "nullable": true, - "minimum": 0 - }, - "terminalDistanceMeters": { - "type": "number", - "nullable": true, - "minimum": 0 - }, - "terminalRadiusMeters": { - "type": "number", - "nullable": true, - "minimum": 10, - "maximum": 1000, - "description": "Terminal geofence radius that was valid when clock-in was accepted." - }, - "terminalMaxAccuracyMeters": { - "type": "number", - "nullable": true, - "minimum": 5, - "maximum": 500, - "description": "Terminal maximum GPS accuracy that was valid when clock-in was accepted." - }, - "positionTimestamp": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "clockOutLatitude": { - "type": "number", - "nullable": true, - "minimum": -90, - "maximum": 90 - }, - "clockOutLongitude": { - "type": "number", - "nullable": true, - "minimum": -180, - "maximum": 180 - }, - "clockOutAccuracyMeters": { - "type": "number", - "nullable": true, - "minimum": 0 - }, - "clockOutTerminalDistanceMeters": { - "type": "number", - "nullable": true, - "minimum": 0 - }, - "clockOutTerminalRadiusMeters": { - "type": "number", - "nullable": true, - "minimum": 10, - "maximum": 1000, - "description": "Terminal geofence radius that was valid when clock-out was accepted." - }, - "clockOutTerminalMaxAccuracyMeters": { - "type": "number", - "nullable": true, - "minimum": 5, - "maximum": 500, - "description": "Terminal maximum GPS accuracy that was valid when clock-out was accepted." - }, - "clockOutPositionTimestamp": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "terminalId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "clockOutTerminalId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "clockInChallengeId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "clockOutChallengeId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "projectId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "projectCode": { - "type": "string", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "serviceOrderId": { - "type": "string", - "format": "uuid", - "nullable": true - }, - "serviceOrderNo": { - "type": "string", - "nullable": true - }, - "serviceOrderTitle": { - "type": "string", - "nullable": true - }, - "activity": { - "type": "string", - "nullable": true - }, - "summary": { - "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/TimeSummaryDto" - } - ] - } - }, - "required": [ - "id", - "employeeId", - "clockIn", - "clockOut", - "source", - "status", - "requiresApproval", - "latitude", - "longitude", - "accuracyMeters", - "terminalDistanceMeters", - "terminalRadiusMeters", - "terminalMaxAccuracyMeters", - "positionTimestamp", - "clockOutLatitude", - "clockOutLongitude", - "clockOutAccuracyMeters", - "clockOutTerminalDistanceMeters", - "clockOutTerminalRadiusMeters", - "clockOutTerminalMaxAccuracyMeters", - "clockOutPositionTimestamp", - "terminalId", - "clockOutTerminalId", - "clockInChallengeId", - "clockOutChallengeId", - "projectId", - "projectCode", - "projectName", - "serviceOrderId", - "serviceOrderNo", - "serviceOrderTitle", - "activity", - "summary" - ] - }, "ScanTerminalResultDto": { "type": "object", "properties": { diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index fc0d384..0ccbc12 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { InstallationModule } from './installation/installation.module'; +import { CustomersModule } from './customers/customers.module'; import { AbsencesModule } from './absences/absences.module'; import { AccountsModule } from './accounts/accounts.module'; import { AttachmentsModule } from './attachments/attachments.module'; @@ -23,6 +25,8 @@ import { WorkSchedulesModule } from './work-schedules/work-schedules.module'; imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, + InstallationModule, + CustomersModule, EventsModule, NotificationsModule, AuthModule, diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index de20900..4c56819 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,9 +1,20 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from './current-user.decorator'; import { AuthService } from './auth.service'; import { LoginDto, + ChangePasswordDto, + UpdateOwnProfileDto, RefreshDto, UpdatePreferencesDto, type EmployeeProfile, @@ -18,6 +29,28 @@ import type { JwtUser } from './jwt.strategy'; export class AuthController { constructor(private readonly auth: AuthService) {} + @Patch('me') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + profile( + @CurrentUser() user: JwtUser, + @Body() dto: UpdateOwnProfileDto, + ): Promise { + return this.auth.updateOwnProfile(user.id, dto); + } + + @Post('password') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + password(@CurrentUser() user: JwtUser, @Body() dto: ChangePasswordDto) { + return this.auth.changePassword( + user.id, + dto.currentPassword, + dto.newPassword, + ); + } + @Post('login') @HttpCode(HttpStatus.OK) login(@Body() dto: LoginDto): Promise { diff --git a/apps/api/src/app/auth/auth.dto.ts b/apps/api/src/app/auth/auth.dto.ts index a7574ca..a50c34a 100644 --- a/apps/api/src/app/auth/auth.dto.ts +++ b/apps/api/src/app/auth/auth.dto.ts @@ -1,6 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { ThemePreference } from '@prisma/client'; -import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; +import { + IsEmail, + IsEnum, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; export class LoginDto { @ApiProperty({ example: 'hannah.roth@openclockwork.test' }) @@ -14,7 +20,10 @@ export class LoginDto { } export class RefreshDto { - @ApiProperty({ description: 'A refresh token previously returned from /auth/login or /auth/refresh.' }) + @ApiProperty({ + description: + 'A refresh token previously returned from /auth/login or /auth/refresh.', + }) @IsString() @MinLength(1) refreshToken!: string; @@ -24,12 +33,28 @@ export class UpdatePreferencesDto { @ApiProperty({ enum: ThemePreference, enumName: 'ThemePreference', - description: 'Light / Dark / System. System follows the OS color-scheme media query in the browser.', + description: + 'Light / Dark / System. System follows the OS color-scheme media query in the browser.', }) @IsEnum(ThemePreference) themePreference!: ThemePreference; } +export class ChangePasswordDto { + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(200) + currentPassword!: string; + @ApiProperty() @IsString() @MinLength(12) @MaxLength(72) newPassword!: string; +} + +export class UpdateOwnProfileDto { + @ApiProperty() @IsString() @MinLength(1) @MaxLength(120) firstName!: string; + @ApiProperty() @IsString() @MinLength(1) @MaxLength(120) lastName!: string; + @ApiProperty() @IsEmail() @MaxLength(200) email!: string; +} + export interface EmployeeProfile { id: string; email: string; diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 597d09c..72aac71 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -1,11 +1,22 @@ import { randomUUID } from 'crypto'; -import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { ThemePreference } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { InstallationService } from '../installation/installation.service'; import type { JwtPayload } from './jwt.strategy'; -import type { EmployeeProfile, LoginResponse, RefreshResponse } from './auth.dto'; +import type { + EmployeeProfile, + LoginResponse, + RefreshResponse, +} from './auth.dto'; const ACCESS_TTL = '15m'; const ACCESS_TTL_SECONDS = 15 * 60; @@ -16,20 +27,25 @@ export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, + private readonly installation: InstallationService, ) {} async login(email: string, password: string): Promise { - const employee = await this.prisma.employee.findUnique({ where: { email } }); + const employee = await this.prisma.employee.findUnique({ + where: { email }, + }); if (!employee || !employee.isActive) { throw new UnauthorizedException('Invalid credentials'); } const ok = await bcrypt.compare(password, employee.passwordHash); if (!ok) throw new UnauthorizedException('Invalid credentials'); + await this.installation.assertActorAccess(employee.id); const tokens = await this.issueTokenPair({ sub: employee.id, email: employee.email, role: employee.role, + ver: employee.authVersion, }); return { ...tokens, @@ -38,23 +54,75 @@ export class AuthService { } async getProfile(employeeId: string): Promise { - const employee = await this.prisma.employee.findUnique({ where: { id: employeeId } }); + const employee = await this.prisma.employee.findUnique({ + where: { id: employeeId }, + }); if (!employee || !employee.isActive) { throw new UnauthorizedException('Account is no longer active'); } return this.toProfile(employee); } + async changePassword( + employeeId: string, + currentPassword: string, + newPassword: string, + ) { + const employee = await this.prisma.employee.findUnique({ + where: { id: employeeId }, + }); + if ( + !employee?.isActive || + !(await bcrypt.compare(currentPassword, employee.passwordHash)) + ) + throw new UnauthorizedException('Current password is incorrect'); + const passwordHash = await bcrypt.hash(newPassword, 10); + const changed = await this.prisma.employee.updateMany({ + where: { id: employeeId, passwordHash: employee.passwordHash }, + data: { passwordHash, authVersion: { increment: 1 } }, + }); + if (!changed.count) + throw new UnauthorizedException( + 'Password already changed; sign in again', + ); + return { changed: true }; + } + + async updateOwnProfile( + employeeId: string, + dto: { firstName: string; lastName: string; email: string }, + ): Promise { + await this.installation.requireOwner(employeeId); + const firstName = dto.firstName.trim(), + lastName = dto.lastName.trim(); + if (!firstName || !lastName) + throw new BadRequestException('A name is required'); + try { + return this.toProfile( + await this.prisma.employee.update({ + where: { id: employeeId }, + data: { firstName, lastName, email: dto.email.trim().toLowerCase() }, + }), + ); + } catch (error) { + if ((error as { code?: string }).code === 'P2002') + throw new ConflictException('Email is already in use'); + throw error; + } + } + async updatePreferences( employeeId: string, themePreference: ThemePreference, ): Promise { - const updated = await this.prisma.employee.update({ - where: { id: employeeId }, - data: { themePreference }, - }).catch(() => { - throw new NotFoundException('Employee not found'); - }); + const updated = await this.prisma.employee + .update({ + where: { id: employeeId }, + data: { themePreference }, + }) + .catch(() => { + throw new NotFoundException('Employee not found'); + }); return this.toProfile(updated); } @@ -88,14 +156,20 @@ export class AuthService { } // Re-check the employee exists and is still active — a deactivation // between login and refresh must invalidate the session. - const employee = await this.prisma.employee.findUnique({ where: { id: payload.sub } }); + const employee = await this.prisma.employee.findUnique({ + where: { id: payload.sub }, + }); if (!employee || !employee.isActive) { throw new UnauthorizedException('Account is no longer active'); } + if ((payload.ver ?? 0) !== employee.authVersion) + throw new UnauthorizedException('Session no longer valid'); + await this.installation.assertActorAccess(employee.id); return this.issueTokenPair({ sub: employee.id, email: employee.email, role: employee.role, + ver: employee.authVersion, }); } @@ -105,7 +179,9 @@ export class AuthService { * by their TTL. We rotate refresh tokens on every refresh so a leaked * pair has at most a 7-day window. */ - private async issueTokenPair(base: Omit): Promise { + private async issueTokenPair( + base: Omit, + ): Promise { // `jwtid` ensures every token has a unique string even when signed at the // same second with the same payload (otherwise login + immediate refresh // would mint identical access tokens). diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index c157803..2f098b6 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import type { Role } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; export type TokenType = 'access' | 'refresh'; @@ -12,17 +13,22 @@ export interface JwtPayload { role: Role; /** Token type — guards against using a refresh-token as an access-token. */ typ: TokenType; + ver?: number; } export interface JwtUser { id: string; email: string; role: Role; + authVersion?: number; } @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(config: ConfigService) { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { const secret = config.get('JWT_SECRET'); if (!secret) throw new Error('JWT_SECRET is not configured'); super({ @@ -32,14 +38,26 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: JwtPayload): JwtUser { + async validate(payload: JwtPayload): Promise { if (!payload?.sub) throw new UnauthorizedException('Malformed token'); // Reject refresh tokens here — they belong only to /api/auth/refresh. // Tokens issued before this guard existed lack `typ`; treat those as // access tokens so existing sessions keep working. if (payload.typ && payload.typ !== 'access') { - throw new UnauthorizedException('Refresh tokens are not valid for API access'); + throw new UnauthorizedException( + 'Refresh tokens are not valid for API access', + ); } - return { id: payload.sub, email: payload.email, role: payload.role }; + const employee = await this.prisma.employee.findUnique({ + where: { id: payload.sub }, + }); + if (!employee?.isActive || (payload.ver ?? 0) !== employee.authVersion) + throw new UnauthorizedException('Session no longer valid'); + return { + id: employee.id, + email: employee.email, + role: employee.role, + authVersion: payload.ver ?? 0, + }; } } diff --git a/apps/api/src/app/customers/customers.controller.ts b/apps/api/src/app/customers/customers.controller.ts new file mode 100644 index 0000000..5569f3d --- /dev/null +++ b/apps/api/src/app/customers/customers.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiCreatedResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { CustomerDto, UpsertCustomerDto } from './customers.dto'; +import { CustomersService } from './customers.service'; + +@ApiTags('customers') +@ApiBearerAuth() +@Controller('customers') +@UseGuards(JwtAuthGuard) +export class CustomersController { + constructor(private readonly customers: CustomersService) {} + + @Get() + @ApiOkResponse({ type: [CustomerDto] }) + list( + @CurrentUser() user: JwtUser, + @Query('includeInactive') includeInactive?: string, + ): Promise { + return this.customers.list(user.id, includeInactive === 'true'); + } + + @Get(':id') + @ApiOkResponse({ type: CustomerDto }) + get( + @CurrentUser() user: JwtUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.customers.get(user.id, id); + } + + @Post() + @ApiCreatedResponse({ type: CustomerDto }) + create( + @CurrentUser() user: JwtUser, + @Body() dto: UpsertCustomerDto, + ): Promise { + return this.customers.create(user, dto); + } + + @Put(':id') + @ApiOkResponse({ type: CustomerDto }) + update( + @CurrentUser() user: JwtUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpsertCustomerDto, + ): Promise { + return this.customers.update(user, id, dto); + } + + @Delete(':id') + @HttpCode(204) + remove( + @CurrentUser() user: JwtUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.customers.remove(user, id); + } +} diff --git a/apps/api/src/app/customers/customers.dto.ts b/apps/api/src/app/customers/customers.dto.ts new file mode 100644 index 0000000..cfe9f28 --- /dev/null +++ b/apps/api/src/app/customers/customers.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +export class UpsertCustomerDto { + @ApiProperty({ maxLength: 200 }) + @IsString() + @Matches(/\S/) + @MaxLength(200) + name!: string; + + @ApiPropertyOptional({ nullable: true, maxLength: 40 }) + @IsOptional() + @IsString() + @MaxLength(40) + code?: string | null; + + @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string | null; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CustomerDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty() + name!: string; + @ApiProperty({ type: String, nullable: true }) + code!: string | null; + @ApiProperty({ type: String, nullable: true }) + note!: string | null; + @ApiProperty() + isActive!: boolean; + @ApiProperty() + projectCount!: number; + @ApiProperty({ format: 'date-time' }) + createdAt!: string; + @ApiProperty({ format: 'date-time' }) + updatedAt!: string; +} diff --git a/apps/api/src/app/customers/customers.module.ts b/apps/api/src/app/customers/customers.module.ts new file mode 100644 index 0000000..0be9cb6 --- /dev/null +++ b/apps/api/src/app/customers/customers.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CustomersController } from './customers.controller'; +import { CustomersService } from './customers.service'; + +@Module({ + imports: [AuthModule], + controllers: [CustomersController], + providers: [CustomersService], +}) +export class CustomersModule {} diff --git a/apps/api/src/app/customers/customers.service.ts b/apps/api/src/app/customers/customers.service.ts new file mode 100644 index 0000000..bd43910 --- /dev/null +++ b/apps/api/src/app/customers/customers.service.ts @@ -0,0 +1,180 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, type Customer } from '@prisma/client'; +import { + INSTALLATION_LOCK, + InstallationService, +} from '../installation/installation.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CustomerDto, UpsertCustomerDto } from './customers.dto'; +import type { JwtUser } from '../auth/jwt.strategy'; + +@Injectable() +export class CustomersService { + constructor( + private readonly prisma: PrismaService, + private readonly installation: InstallationService, + ) {} + + async list( + actorId: string, + includeInactive: boolean, + ): Promise { + await this.installation.requireOwner(actorId); + const rows = await this.prisma.customer.findMany({ + where: includeInactive ? undefined : { isActive: true }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + include: { _count: { select: { projects: true } } }, + }); + return rows.map(toDto); + } + + async get(actorId: string, id: string): Promise { + await this.installation.requireOwner(actorId); + return toDto(await this.findOrThrow(this.prisma, id)); + } + + async create(actor: JwtUser, dto: UpsertCustomerDto): Promise { + await this.installation.requireOwner(actor.id); + try { + return await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireMutationActor(actor, tx); + return toDto( + await tx.customer.create({ + data: { + name: dto.name.trim(), + code: dto.code?.trim() || null, + note: dto.note?.trim() || null, + isActive: dto.isActive ?? true, + }, + include: { _count: { select: { projects: true } } }, + }), + ); + }); + } catch (error) { + this.rethrowConflict(error); + } + } + + async update( + actor: JwtUser, + id: string, + dto: UpsertCustomerDto, + ): Promise { + await this.installation.requireOwner(actor.id); + try { + return await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireMutationActor(actor, tx); + await tx.$queryRaw`SELECT "id" FROM "Customer" WHERE "id" = ${id}::uuid FOR UPDATE`; + const current = await this.findOrThrow(tx, id); + if (current.isActive && dto.isActive === false) { + const open = await tx.timeEntry.count({ + where: { + project: { customerId: id }, + clockOut: null, + voidedAt: null, + status: { not: 'Rejected' }, + }, + }); + if (open > 0) + throw new ConflictException( + 'Finish or reassign the running timer before archiving this customer', + ); + } + return toDto( + await tx.customer.update({ + where: { id }, + data: { + name: dto.name.trim(), + code: + dto.code === undefined ? undefined : dto.code?.trim() || null, + note: + dto.note === undefined ? undefined : dto.note?.trim() || null, + isActive: dto.isActive, + }, + include: { _count: { select: { projects: true } } }, + }), + ); + }); + } catch (error) { + this.rethrowConflict(error); + } + } + + async remove(actor: JwtUser, id: string): Promise { + await this.installation.requireOwner(actor.id); + try { + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireMutationActor(actor, tx); + await tx.$queryRaw`SELECT "id" FROM "Customer" WHERE "id" = ${id}::uuid FOR UPDATE`; + const current = await this.findOrThrow(tx, id); + if (current._count.projects > 0) + throw new ConflictException( + 'Customer has projects and cannot be deleted; archive it instead', + ); + await tx.customer.delete({ where: { id } }); + }); + } catch (error) { + this.rethrowConflict(error); + } + } + + private async findOrThrow(tx: Prisma.TransactionClient, id: string) { + const row = await tx.customer.findUnique({ + where: { id }, + include: { _count: { select: { projects: true } } }, + }); + if (!row) throw new NotFoundException('Customer not found'); + return row; + } + + private async requireMutationActor( + actor: JwtUser, + tx: Prisma.TransactionClient, + ): Promise { + await this.installation.requireOwner(actor.id, tx); + const employee = await tx.employee.findUniqueOrThrow({ + where: { id: actor.id }, + }); + if ((actor.authVersion ?? 0) !== employee.authVersion) + throw new ForbiddenException( + 'Customer management session is no longer authorized', + ); + } + + private rethrowConflict(error: unknown): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) + throw new ConflictException('A customer with this code already exists'); + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) + throw new ConflictException( + 'Customer is referenced and cannot be deleted; archive it instead', + ); + throw error; + } +} + +function toDto(row: Customer & { _count: { projects: number } }): CustomerDto { + return { + id: row.id, + name: row.name, + code: row.code, + note: row.note, + isActive: row.isActive, + projectCount: row._count.projects, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/apps/api/src/app/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts index 8d1b906..0234c26 100644 --- a/apps/api/src/app/employees/employees.service.ts +++ b/apps/api/src/app/employees/employees.service.ts @@ -51,27 +51,38 @@ export class EmployeesService { } const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS); try { - const created = await this.prisma.employee.create({ - data: { - personalNo: dto.personalNo, - firstName: dto.firstName, - lastName: dto.lastName, - email: dto.email.toLowerCase(), - passwordHash, - role: dto.role, - timeModel: dto.timeModel, - weeklyHours: dto.weeklyHours, - annualLeaveDays: dto.annualLeaveDays, - startDate: new Date(dto.startDate), - overtimeOpeningBalanceMinutes: dto.overtimeOpeningBalanceMinutes ?? 0, - ...holidaySettings(dto), - holidayDates: dto.holidayDates ?? [], - allowDailyBlockBooking: dto.allowDailyBlockBooking ?? false, - isActive: true, - managerId: dto.managerId ?? null, - workScheduleId: dto.workScheduleId ?? null, - }, - include: { workSchedule: true }, + const created = await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; + const settings = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + if (settings?.mode === 'Solo') + throw new ForbiddenException( + 'Enable Team mode before creating another employee', + ); + return tx.employee.create({ + data: { + personalNo: dto.personalNo, + firstName: dto.firstName, + lastName: dto.lastName, + email: dto.email.toLowerCase(), + passwordHash, + role: dto.role, + timeModel: dto.timeModel, + weeklyHours: dto.weeklyHours, + annualLeaveDays: dto.annualLeaveDays, + startDate: new Date(dto.startDate), + overtimeOpeningBalanceMinutes: + dto.overtimeOpeningBalanceMinutes ?? 0, + ...holidaySettings(dto), + holidayDates: dto.holidayDates ?? [], + allowDailyBlockBooking: dto.allowDailyBlockBooking ?? false, + isActive: true, + managerId: dto.managerId ?? null, + workScheduleId: dto.workScheduleId ?? null, + }, + include: { workSchedule: true }, + }); }); return toEmployeeDto(created); } catch (err) { @@ -131,10 +142,38 @@ export class EmployeesService { : { disconnect: true }; } try { - const updated = await this.prisma.employee.update({ - where: { id }, - data, - include: { workSchedule: true }, + const updated = await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; + const settings = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + if (settings?.mode === 'Solo') + throw new ForbiddenException( + 'Use personal settings or enable Team mode before changing employee records', + ); + const lockedCurrent = await tx.employee.findUniqueOrThrow({ + where: { id }, + }); + if ( + lockedCurrent.role === 'HRAdmin' && + lockedCurrent.isActive && + (dto.isActive === false || + (dto.role !== undefined && dto.role !== 'HRAdmin')) + ) { + if ( + !(await tx.employee.count({ + where: { role: 'HRAdmin', isActive: true, id: { not: id } }, + })) + ) + throw new ForbiddenException( + 'Cannot remove the last active administrator', + ); + } + return tx.employee.update({ + where: { id }, + data, + include: { workSchedule: true }, + }); }); return toEmployeeDto(updated); } catch (err) { @@ -147,7 +186,7 @@ export class EmployeesService { const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); await this.prisma.employee.update({ where: { id }, - data: { passwordHash }, + data: { passwordHash, authVersion: { increment: 1 } }, }); } diff --git a/apps/api/src/app/events/events.gateway.ts b/apps/api/src/app/events/events.gateway.ts index cb27a74..2b5d19a 100644 --- a/apps/api/src/app/events/events.gateway.ts +++ b/apps/api/src/app/events/events.gateway.ts @@ -1,8 +1,23 @@ import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; +import { + OnGatewayConnection, + OnGatewayDisconnect, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; import type { Server, Socket } from 'socket.io'; import type { JwtPayload, JwtUser } from '../auth/jwt.strategy'; +import { PrismaService } from '../prisma/prisma.service'; +import { InstallationService } from '../installation/installation.service'; + +interface AuthorizedSocket { + client: Socket; + employeeId: string; + authVersion: number; + expiresAt: number; + expiryTimer: ReturnType; +} /** * Realtime events fanout. The HTTP API is the source of truth — sockets @@ -19,13 +34,18 @@ import type { JwtPayload, JwtUser } from '../auth/jwt.strategy'; }) export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly logger = new Logger(EventsGateway.name); + private readonly connections = new Map(); @WebSocketServer() server!: Server; - constructor(private readonly jwt: JwtService) {} + constructor( + private readonly jwt: JwtService, + private readonly prisma: PrismaService, + private readonly installation: InstallationService, + ) {} - handleConnection(client: Socket): void { + async handleConnection(client: Socket): Promise { const token = extractToken(client); if (!token) { this.logger.warn(`socket ${client.id} rejected: no token`); @@ -33,25 +53,150 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { return; } try { - const payload = this.jwt.verify(token); - const user: JwtUser = { id: payload.sub, email: payload.email, role: payload.role }; + const payload = this.jwt.verify(token); + // Pre-typ access tokens remain compatible, exactly as in JwtStrategy. + if ( + !payload.sub || + (payload.typ && payload.typ !== 'access') || + !Number.isFinite(payload.exp) + ) { + throw new Error('Invalid access token'); + } + const employee = await this.prisma.employee.findUnique({ + where: { id: payload.sub }, + }); + if (!employee?.isActive || employee.authVersion !== (payload.ver ?? 0)) + throw new Error('Session no longer valid'); + await this.installation.assertActorAccess(employee.id); + if (await this.installation.isSolo()) + await this.installation.requireOwner(employee.id); + const expiresAt = (payload.exp as number) * 1000; + if (expiresAt <= Date.now() || !client.connected) { + client.disconnect(true); + return; + } + const user: JwtUser = { + id: employee.id, + email: employee.email, + role: employee.role, + }; (client.data as { user: JwtUser }).user = user; - this.logger.log(`socket connected: ${client.id} (${user.email})`); - } catch (err) { - const reason = err instanceof Error ? err.message : 'invalid token'; - this.logger.warn(`socket ${client.id} rejected: ${reason}`); + const expiryTimer = setTimeout( + () => this.disconnect(client), + Math.min(expiresAt - Date.now(), 2_147_483_647), + ); + expiryTimer.unref(); + this.connections.set(client.id, { + client, + employeeId: employee.id, + authVersion: employee.authVersion, + expiresAt, + expiryTimer, + }); + this.logger.log(`socket connected: ${client.id}`); + } catch { + this.logger.warn( + `socket ${client.id} rejected: invalid or unauthorized session`, + ); client.disconnect(true); } } handleDisconnect(client: Socket): void { - const user = (client.data as { user?: JwtUser }).user; - this.logger.log(`socket disconnected: ${client.id}${user ? ` (${user.email})` : ''}`); + const connection = this.connections.get(client.id); + if (connection) clearTimeout(connection.expiryTimer); + this.connections.delete(client.id); + this.logger.log(`socket disconnected: ${client.id}`); } - broadcast(event: string, payload: T): void { + async broadcast(event: string, payload: T): Promise { if (!this.server) return; - this.server.emit(event, payload); + // Never fan out via server.emit: it includes sockets whose async handshake + // has not passed authorization yet and sessions invalidated after connect. + const recipients = await this.revalidateConnections(); + for (const client of recipients) { + const ownerId = (client.data as { soloOwnerId?: string | null }) + .soloOwnerId; + const subject = + typeof payload === 'object' && + payload !== null && + 'employeeId' in payload + ? payload.employeeId + : undefined; + if (ownerId && subject !== undefined && subject !== ownerId) continue; + if (client.connected) client.emit(event, payload); + } + } + + /** Also callable after an identity/mode change to disconnect immediately. */ + async revalidateConnections(): Promise { + const connections = [...this.connections.values()]; + if (!connections.length) return []; + try { + const [settings, employees] = await Promise.all([ + this.installation.getSettings(), + this.prisma.employee.findMany({ + where: { + id: { + in: [ + ...new Set( + connections.map((connection) => connection.employeeId), + ), + ], + }, + }, + select: { + id: true, + isActive: true, + authVersion: true, + email: true, + role: true, + }, + }), + ]); + const byId = new Map( + employees.map((employee) => [employee.id, employee]), + ); + const recipients: Socket[] = []; + for (const connection of connections) { + const employee = byId.get(connection.employeeId); + if ( + !employee?.isActive || + employee.authVersion !== connection.authVersion || + connection.expiresAt <= Date.now() || + (settings.mode === 'Solo' && + (settings.ownerEmployeeId !== employee.id || + employee.role !== 'HRAdmin')) + ) { + this.disconnect(connection.client); + continue; + } + (connection.client.data as { user: JwtUser }).user = { + id: employee.id, + email: employee.email, + role: employee.role, + }; + (connection.client.data as { soloOwnerId: string | null }).soloOwnerId = + settings.mode === 'Solo' ? settings.ownerEmployeeId : null; + recipients.push(connection.client); + } + return recipients; + } catch { + // A database failure must not widen event visibility. Clients reconnect + // through the ordinary authenticated handshake after the outage. + for (const connection of connections) this.disconnect(connection.client); + this.logger.warn( + 'Realtime authorization unavailable; connections closed', + ); + return []; + } + } + + private disconnect(client: Socket): void { + const connection = this.connections.get(client.id); + if (connection) clearTimeout(connection.expiryTimer); + this.connections.delete(client.id); + client.disconnect(true); } } diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index e857dfe..3af22ac 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -9,7 +9,7 @@ export class HealthResponseDto { @ApiProperty({ example: 'openclockwork-api' }) service!: string; - @ApiProperty({ example: '1.4.0' }) + @ApiProperty({ example: '2.0.0' }) version!: string; @ApiProperty({ format: 'date-time' }) diff --git a/apps/api/src/app/installation/installation.controller.ts b/apps/api/src/app/installation/installation.controller.ts new file mode 100644 index 0000000..8ee5c07 --- /dev/null +++ b/apps/api/src/app/installation/installation.controller.ts @@ -0,0 +1,132 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { + ChangeModeDto, + EditPersonalDayDto, + PersonalDayDto, + PreviewModeDto, + RevisionDto, + UpdateSoloSettingsDto, +} from './installation.dto'; +import { InstallationService } from './installation.service'; +import { PersonalSummaryService } from './personal-summary.service'; +import { + PersonalHintsDto, + PersonalHintsService, +} from './personal-hints.service'; +import { + InstallationAuditResponse, + InstallationStateResponse, + ModePreviewResponse, + PersonalDayResponse, + PersonalSummaryResponse, +} from './installation.response'; + +@ApiTags('installation') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('installation') +export class InstallationController { + constructor( + private readonly installation: InstallationService, + private readonly summary: PersonalSummaryService, + private readonly personalHints: PersonalHintsService, + ) {} + @Get('hints') @ApiOkResponse({ type: PersonalHintsDto }) hints( + @CurrentUser() user: JwtUser, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + return this.personalHints.hints(user.id, from, to); + } + @Get() @ApiOkResponse({ type: InstallationStateResponse }) get( + @CurrentUser() user: JwtUser, + ) { + return this.installation.getState(user.id); + } + @Patch('settings') + @ApiOkResponse({ type: InstallationStateResponse }) + settings(@CurrentUser() user: JwtUser, @Body() dto: UpdateSoloSettingsDto) { + return this.installation.saveSettings(user.id, dto); + } + @Post('complete-setup') + @ApiCreatedResponse({ type: InstallationStateResponse }) + complete(@CurrentUser() user: JwtUser) { + return this.installation.completeSetup(user.id); + } + @Post('mode-preview') + @ApiCreatedResponse({ type: ModePreviewResponse }) + preview(@CurrentUser() user: JwtUser, @Body() dto: PreviewModeDto) { + return this.installation.previewMode(user.id, dto.mode); + } + @Post('mode') @ApiCreatedResponse({ type: InstallationStateResponse }) mode( + @CurrentUser() user: JwtUser, + @Body() dto: ChangeModeDto, + ) { + return this.installation.changeMode(user.id, dto); + } + @Get('days') @ApiOkResponse({ type: [PersonalDayResponse] }) days( + @CurrentUser() user: JwtUser, + ) { + return this.installation.days(user.id); + } + @Post('days') @ApiCreatedResponse({ type: PersonalDayResponse }) createDay( + @CurrentUser() user: JwtUser, + @Body() dto: PersonalDayDto, + ) { + return this.installation.saveDay(user.id, dto); + } + @Patch('days/:id') @ApiOkResponse({ type: PersonalDayResponse }) updateDay( + @CurrentUser() user: JwtUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: EditPersonalDayDto, + ) { + return this.installation.saveDay(user.id, dto, id); + } + @Delete('days/:id') @ApiOkResponse({ type: PersonalDayResponse }) cancelDay( + @CurrentUser() user: JwtUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RevisionDto, + ) { + return this.installation.cancelDay(user.id, id, dto.revision); + } + @Get('events') @ApiOkResponse({ type: [InstallationAuditResponse] }) events( + @CurrentUser() user: JwtUser, + ) { + return this.installation.events(user.id); + } + @Get('days/:id/audit') + @ApiOkResponse({ type: [InstallationAuditResponse] }) + dayAudit( + @CurrentUser() user: JwtUser, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.installation.dayAudit(user.id, id); + } + @Get('summary') @ApiOkResponse({ type: PersonalSummaryResponse }) totals( + @CurrentUser() user: JwtUser, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + return this.summary.summary(user.id, from, to); + } +} diff --git a/apps/api/src/app/installation/installation.dto.ts b/apps/api/src/app/installation/installation.dto.ts new file mode 100644 index 0000000..6993d04 --- /dev/null +++ b/apps/api/src/app/installation/installation.dto.ts @@ -0,0 +1,138 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export class SoloBreakRuleDto { + @ApiProperty() @IsInt() @Min(0) @Max(1440) afterMinutes!: number; + @ApiProperty() @IsInt() @Min(0) @Max(1440) breakMinutes!: number; +} + +export class SoloCoreWindowDto { + @ApiProperty() @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) start!: string; + @ApiProperty() @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) end!: string; + @ApiProperty() @IsInt() @Min(1) @Max(127) weekdays!: number; + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + label?: string; +} + +export class UpdateSoloSettingsDto { + @ApiProperty() @IsInt() @Min(0) revision!: number; + @ApiProperty({ example: '2026-09-08' }) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + effectiveFrom!: string; + @ApiProperty() @IsBoolean() targetEnabled!: boolean; + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10080) + weeklyTargetMinutes?: number | null; + @ApiProperty() @IsInt() @Min(1) @Max(127) workingDays!: number; + @ApiProperty() @IsBoolean() leaveEnabled!: boolean; + @ApiProperty() @IsNumber() @Min(0) @Max(366) annualLeaveDays!: number; + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + @Max(366) + carryOverDays?: number; + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + carryOverExpiresOn?: string | null; + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(-366) + @Max(366) + leaveAdjustmentDays?: number; + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + @MaxLength(1000) + leaveAdjustmentReason?: string | null; + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(1900) + @Max(9999) + leaveAllowanceYear?: number; + @ApiProperty() + @Matches(/^(NONE|DE-(BW|BY|BE|BB|HB|HH|HE|MV|NI|NW|RP|SL|SN|ST|SH|TH))$/) + holidayCalendar!: string; + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMaxSize(3660) + @Matches(/^\d{4}-\d{2}-\d{2}$/, { each: true }) + holidayDates!: string[]; + @ApiProperty({ type: [SoloBreakRuleDto] }) + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => SoloBreakRuleDto) + breakRules!: SoloBreakRuleDto[]; + @ApiProperty() @IsBoolean() coreTimeHintsEnabled!: boolean; + @ApiPropertyOptional() + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + frameStart?: string; + @ApiPropertyOptional() + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + frameEnd?: string; + @ApiPropertyOptional({ type: [SoloCoreWindowDto] }) + @IsOptional() + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => SoloCoreWindowDto) + coreTimes?: SoloCoreWindowDto[]; + @ApiProperty() @IsBoolean() dailyBlockEnabled!: boolean; + @ApiProperty() @IsBoolean() gpsEnabled!: boolean; +} + +export class PreviewModeDto { + @ApiProperty({ enum: ['Team', 'Solo'] }) @IsIn(['Team', 'Solo']) mode!: + | 'Team' + | 'Solo'; +} +export class ChangeModeDto extends PreviewModeDto { + @ApiProperty() @IsInt() @Min(0) revision!: number; +} +export class PersonalDayDto { + @ApiProperty({ enum: ['Free', 'Vacation', 'Sickness', 'Training'] }) + @IsIn(['Free', 'Vacation', 'Sickness', 'Training']) + kind!: string; + @ApiProperty() @Matches(/^\d{4}-\d{2}-\d{2}$/) from!: string; + @ApiProperty() @Matches(/^\d{4}-\d{2}-\d{2}$/) to!: string; + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string | null; + @ApiPropertyOptional() @IsOptional() @IsBoolean() halfDayStart?: boolean; + @ApiPropertyOptional() @IsOptional() @IsBoolean() halfDayEnd?: boolean; +} +export class EditPersonalDayDto extends PersonalDayDto { + @ApiProperty() @IsInt() @Min(0) revision!: number; +} +export class RevisionDto { + @ApiProperty() @IsInt() @Min(0) revision!: number; +} diff --git a/apps/api/src/app/installation/installation.module.ts b/apps/api/src/app/installation/installation.module.ts new file mode 100644 index 0000000..59ce84f --- /dev/null +++ b/apps/api/src/app/installation/installation.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { InstallationController } from './installation.controller'; +import { InstallationService } from './installation.service'; +import { PersonalSummaryService } from './personal-summary.service'; +import { PersonalHintsService } from './personal-hints.service'; +import { SoloAccessGuard } from './solo-access.guard'; + +@Global() +@Module({ + controllers: [InstallationController], + providers: [ + InstallationService, + PersonalSummaryService, + PersonalHintsService, + { provide: APP_GUARD, useClass: SoloAccessGuard }, + ], + exports: [InstallationService], +}) +export class InstallationModule {} diff --git a/apps/api/src/app/installation/installation.response.ts b/apps/api/src/app/installation/installation.response.ts new file mode 100644 index 0000000..fd12b6c --- /dev/null +++ b/apps/api/src/app/installation/installation.response.ts @@ -0,0 +1,133 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SoloCapabilitiesResponse { + @ApiProperty() isOwner!: boolean; + @ApiProperty() solo!: boolean; + @ApiProperty() targets!: boolean; + @ApiProperty() leave!: boolean; + @ApiProperty() coreTimeHints!: boolean; + @ApiProperty() dailyBlock!: boolean; + @ApiProperty() gps!: boolean; +} +export class SoloBreakRuleResponse { + @ApiProperty() afterMinutes!: number; + @ApiProperty() breakMinutes!: number; +} +export class SoloCoreWindowResponse { + @ApiProperty() start!: string; + @ApiProperty() end!: string; + @ApiProperty() weekdays!: number; + @ApiPropertyOptional({ nullable: true }) label?: string | null; +} +export class SoloPolicyResponse { + @ApiProperty({ type: String, nullable: true }) id!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + effectiveFrom!: string | null; + @ApiProperty() targetEnabled!: boolean; + @ApiProperty({ type: Number, nullable: true }) weeklyTargetMinutes!: + | number + | null; + @ApiProperty() workingDays!: number; + @ApiProperty() leaveEnabled!: boolean; + @ApiProperty() annualLeaveDays!: number; + @ApiProperty() carryOverDays!: number; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + carryOverExpiresOn!: string | null; + @ApiProperty() leaveAdjustmentDays!: number; + @ApiProperty({ type: String, nullable: true }) leaveAdjustmentReason!: + | string + | null; + @ApiProperty() leaveAllowanceYear!: number; + @ApiProperty() holidayCalendar!: string; + @ApiProperty({ type: [String] }) holidayDates!: string[]; + @ApiProperty({ type: [SoloBreakRuleResponse] }) + breakRules!: SoloBreakRuleResponse[]; + @ApiProperty() coreTimeHintsEnabled!: boolean; + @ApiProperty() dailyBlockEnabled!: boolean; + @ApiProperty() gpsEnabled!: boolean; + @ApiProperty() frameStart!: string; + @ApiProperty() frameEnd!: string; + @ApiProperty({ type: [SoloCoreWindowResponse] }) + coreTimes!: SoloCoreWindowResponse[]; +} +export class InstallationStateResponse { + @ApiProperty({ enum: ['Team', 'Solo'] }) mode!: 'Team' | 'Solo'; + @ApiProperty({ type: String, nullable: true }) ownerEmployeeId!: + | string + | null; + @ApiProperty() setupCompleted!: boolean; + @ApiProperty() revision!: number; + @ApiProperty() timeZone!: string; + @ApiProperty({ type: SoloCapabilitiesResponse }) + capabilities!: SoloCapabilitiesResponse; + @ApiProperty({ type: SoloPolicyResponse }) policy!: SoloPolicyResponse; + @ApiProperty({ type: [SoloPolicyResponse] }) + futurePolicies!: SoloPolicyResponse[]; +} +export class ModePreviewResponse { + @ApiProperty() allowed!: boolean; + @ApiProperty({ type: [String] }) blockers!: string[]; +} +export class PersonalDayResponse { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ enum: ['Free', 'Vacation', 'Sickness', 'Training'] }) + kind!: string; + @ApiProperty({ format: 'date' }) from!: string; + @ApiProperty({ format: 'date' }) to!: string; + @ApiProperty({ type: String, nullable: true }) note!: string | null; + @ApiProperty() halfDayStart!: boolean; + @ApiProperty() halfDayEnd!: boolean; + @ApiProperty() revision!: number; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + cancelledAt!: string | null; +} +export class PersonalSummaryResponse { + @ApiProperty({ format: 'date' }) from!: string; + @ApiProperty({ format: 'date' }) to!: string; + @ApiProperty() timeZone!: string; + @ApiProperty() targetEnabled!: boolean; + @ApiProperty() leaveEnabled!: boolean; + @ApiProperty() actualMinutes!: number; + @ApiProperty({ + type: Number, + nullable: true, + description: 'Actual net minutes on dates with an enabled Solo target.', + }) + targetActualMinutes!: number | null; + @ApiProperty({ type: Number, nullable: true }) targetMinutes!: number | null; + @ApiProperty({ type: Number, nullable: true }) overtimeMinutes!: + | number + | null; + @ApiProperty({ type: Number, nullable: true }) vacationDaysTotal!: + | number + | null; + @ApiProperty({ type: Number, nullable: true }) vacationDaysUsed!: + | number + | null; + @ApiProperty({ type: Number, nullable: true }) vacationDaysRemaining!: + | number + | null; + @ApiProperty() vacationAllowanceYear!: number; + @ApiProperty({ type: Number, nullable: true }) vacationDaysCarryOver!: + | number + | null; + @ApiProperty({ type: Number, nullable: true }) vacationDaysCarryOverUsed!: + | number + | null; + @ApiProperty({ type: Number, nullable: true }) vacationDaysCarryOverExpired!: + | number + | null; + @ApiProperty({ type: Number, nullable: true }) vacationDaysAdjustment!: + | number + | null; +} +export class InstallationAuditResponse { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ type: String, nullable: true }) actorId!: string | null; + @ApiProperty() action!: string; + @ApiProperty({ type: 'object', additionalProperties: true, nullable: true }) + before!: unknown; + @ApiProperty({ type: 'object', additionalProperties: true, nullable: true }) + after!: unknown; + @ApiProperty({ format: 'date-time' }) occurredAt!: string; +} diff --git a/apps/api/src/app/installation/installation.service.ts b/apps/api/src/app/installation/installation.service.ts new file mode 100644 index 0000000..03c0938 --- /dev/null +++ b/apps/api/src/app/installation/installation.service.ts @@ -0,0 +1,658 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { Prisma, type PersonalDay, type SoloPolicy } from '@prisma/client'; +import { parseBreakRules, type BreakRule } from 'shared'; +import { PrismaService } from '../prisma/prisma.service'; +import type { + ChangeModeDto, + EditPersonalDayDto, + PersonalDayDto, + UpdateSoloSettingsDto, +} from './installation.dto'; +import { + parsePersonalWindows, + validatePersonalFrame, + type PersonalCoreWindow, +} from './personal-windows'; + +export const INSTALLATION_LOCK = 7261500; +export const DEFAULT_SOLO_POLICY = { + targetEnabled: false, + weeklyTargetMinutes: null as number | null, + workingDays: 31, + leaveEnabled: false, + annualLeaveDays: 0, + holidayCalendar: 'NONE', + holidayDates: [] as string[], + carryOverDays: 0, + carryOverExpiresOn: null as Date | null, + leaveAdjustmentDays: 0, + leaveAdjustmentReason: null as string | null, + leaveAllowanceYear: new Date().getFullYear(), + breakRules: [] as BreakRule[], + coreTimeHintsEnabled: false, + dailyBlockEnabled: false, + gpsEnabled: false, + frameStart: '00:00', + frameEnd: '23:59', + coreTimes: [] as PersonalCoreWindow[], +}; + +export function dateOnly(value: string): Date { + const parsed = new Date(`${value}T00:00:00.000Z`); + if ( + !/^\d{4}-\d{2}-\d{2}$/.test(value) || + !Number.isFinite(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== value + ) { + throw new BadRequestException('Invalid calendar date'); + } + return parsed; +} +export function localDate(value = new Date()): string { + return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; +} +export function jsonValue(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} +function policyValue(row: SoloPolicy | null) { + if (!row) + return { + ...DEFAULT_SOLO_POLICY, + id: null as string | null, + effectiveFrom: null as string | null, + }; + return { + ...row, + effectiveFrom: row.effectiveFrom.toISOString().slice(0, 10), + annualLeaveDays: Number(row.annualLeaveDays), + carryOverDays: Number(row.carryOverDays), + carryOverExpiresOn: + row.carryOverExpiresOn?.toISOString().slice(0, 10) ?? null, + leaveAdjustmentDays: Number(row.leaveAdjustmentDays), + leaveAllowanceYear: + row.leaveAllowanceYear ?? row.effectiveFrom.getUTCFullYear(), + breakRules: parseBreakRules(row.breakRules), + coreTimes: parsePersonalWindows(row.coreTimes), + }; +} +function dayValue(row: PersonalDay) { + return { + ...row, + from: row.from.toISOString().slice(0, 10), + to: row.to.toISOString().slice(0, 10), + }; +} + +@Injectable() +export class InstallationService { + constructor(private readonly prisma: PrismaService) {} + + async getSettings(tx: Prisma.TransactionClient = this.prisma) { + return ( + (await tx.installationSettings.findUnique({ where: { id: 1 } })) ?? { + id: 1, + mode: 'Team' as const, + ownerEmployeeId: null, + setupCompleted: true, + revision: 0, + } + ); + } + async isSolo(): Promise { + return (await this.getSettings()).mode === 'Solo'; + } + + async assertActorAccess(employeeId: string): Promise { + const [actor, settings] = await Promise.all([ + this.prisma.employee.findUnique({ where: { id: employeeId } }), + this.getSettings(), + ]); + if (!actor?.isActive) + throw new UnauthorizedException('Account is no longer active'); + if (settings.mode === 'Solo' && settings.ownerEmployeeId !== employeeId) + throw new ForbiddenException('Solo owner access required'); + } + + async requireOwner( + employeeId: string, + tx: Prisma.TransactionClient = this.prisma, + ): Promise { + const [settings, actor] = await Promise.all([ + this.getSettings(tx), + tx.employee.findUnique({ where: { id: employeeId } }), + ]); + if ( + settings.mode !== 'Solo' || + settings.ownerEmployeeId !== employeeId || + !actor?.isActive || + actor.role !== 'HRAdmin' + ) { + throw new ForbiddenException('Solo owner access required'); + } + } + + async policyFor(employeeId: string, at = new Date()) { + const settings = await this.getSettings(); + const row = await this.prisma.soloPolicy.findFirst({ + where: { employeeId, effectiveFrom: { lte: dateOnly(localDate(at)) } }, + orderBy: [ + { effectiveFrom: 'desc' }, + { createdAt: 'desc' }, + { id: 'desc' }, + ], + }); + return { + ...policyValue(row), + isSolo: + settings.mode === 'Solo' && settings.ownerEmployeeId === employeeId, + }; + } + + async getState(actorId: string) { + await this.assertActorAccess(actorId); + const settings = await this.getSettings(); + const policy = await this.policyFor(actorId); + const futurePolicies = await this.prisma.soloPolicy.findMany({ + where: { + employeeId: actorId, + effectiveFrom: { gt: dateOnly(localDate()) }, + }, + orderBy: [{ effectiveFrom: 'asc' }, { createdAt: 'asc' }], + }); + const isOwner = + settings.mode === 'Solo' && settings.ownerEmployeeId === actorId; + return { + ...settings, + timeZone: + process.env.TZ || + Intl.DateTimeFormat().resolvedOptions().timeZone || + 'UTC', + capabilities: { + isOwner, + solo: isOwner, + targets: isOwner && policy.targetEnabled, + leave: isOwner && policy.leaveEnabled, + coreTimeHints: isOwner && policy.coreTimeHintsEnabled, + dailyBlock: isOwner && policy.dailyBlockEnabled && policy.targetEnabled, + gps: isOwner && policy.gpsEnabled, + }, + policy, + futurePolicies: futurePolicies.map(policyValue), + }; + } + + async saveSettings(actorId: string, dto: UpdateSoloSettingsDto) { + const effective = dateOnly(dto.effectiveFrom); + if (effective < dateOnly(localDate())) + throw new BadRequestException('Policies cannot be changed retroactively'); + if ( + dto.targetEnabled && + (!dto.weeklyTargetMinutes || dto.weeklyTargetMinutes <= 0) + ) + throw new BadRequestException( + 'An enabled target requires positive weekly minutes', + ); + if (dto.dailyBlockEnabled && !dto.targetEnabled) + throw new BadRequestException('Daily blocks require an enabled target'); + dto.holidayDates.forEach(dateOnly); + if (dto.carryOverExpiresOn) dateOnly(dto.carryOverExpiresOn); + if (dto.leaveAdjustmentDays && !dto.leaveAdjustmentReason?.trim()) + throw new BadRequestException('A leave adjustment requires a reason'); + let rules: BreakRule[]; + try { + rules = parseBreakRules(dto.breakRules); + } catch { + throw new BadRequestException('Invalid break rules'); + } + const { revision, effectiveFrom: _effectiveFrom, ...fields } = dto; + void _effectiveFrom; + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireOwner(actorId, tx); + const changed = await tx.installationSettings.updateMany({ + where: { id: 1, revision }, + data: { revision: { increment: 1 } }, + }); + if (!changed.count) + throw new ConflictException('Settings changed; reload before saving'); + // Once a day has completed work or a personal calendar entry, protect its historical target/calendar. + if (effective.getTime() === dateOnly(localDate()).getTime()) { + if (await this.hasAccountingHistory(tx, actorId, effective)) + throw new ConflictException( + 'Today already has recorded work; choose a future effective date', + ); + } + const previous = await tx.soloPolicy.findFirst({ + where: { employeeId: actorId, effectiveFrom: { lte: effective } }, + orderBy: [{ effectiveFrom: 'desc' }, { createdAt: 'desc' }], + }); + const frameStart = + fields.frameStart ?? + previous?.frameStart ?? + DEFAULT_SOLO_POLICY.frameStart; + const frameEnd = + fields.frameEnd ?? previous?.frameEnd ?? DEFAULT_SOLO_POLICY.frameEnd; + let coreTimes: PersonalCoreWindow[]; + try { + coreTimes = parsePersonalWindows( + fields.coreTimes ?? previous?.coreTimes ?? [], + ); + validatePersonalFrame(frameStart, frameEnd, coreTimes); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Invalid personal windows', + ); + } + const policy = await tx.soloPolicy.create({ + data: { + ...fields, + weeklyTargetMinutes: fields.targetEnabled + ? fields.weeklyTargetMinutes + : null, + frameStart, + frameEnd, + coreTimes: jsonValue(coreTimes), + breakRules: jsonValue(rules), + employeeId: actorId, + effectiveFrom: effective, + carryOverExpiresOn: fields.carryOverExpiresOn + ? dateOnly(fields.carryOverExpiresOn) + : null, + leaveAllowanceYear: + fields.leaveAllowanceYear ?? effective.getUTCFullYear(), + }, + }); + await tx.installationEvent.create({ + data: { + actorId, + action: 'PolicyChanged', + before: jsonValue(previous), + after: jsonValue(policy), + }, + }); + }); + return this.getState(actorId); + } + + async completeSetup(actorId: string) { + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireOwner(actorId, tx); + await tx.installationSettings.update({ + where: { id: 1 }, + data: { setupCompleted: true, revision: { increment: 1 } }, + }); + }); + return this.getState(actorId); + } + + private async modeBlockers( + actorId: string, + mode: 'Team' | 'Solo', + tx: Prisma.TransactionClient, + ) { + const actor = await tx.employee.findUnique({ where: { id: actorId } }); + if (!actor?.isActive || actor.role !== 'HRAdmin') + throw new ForbiddenException('Administrator access required'); + const settings = await this.getSettings(tx); + if (settings.mode === 'Solo' && settings.ownerEmployeeId !== actorId) + throw new ForbiddenException('Solo owner access required'); + if (settings.mode === mode) return []; + const blockers: string[] = []; + if (await tx.timeEntry.count({ where: { clockOut: null, voidedAt: null } })) + blockers.push('OPEN_TIME_ENTRIES'); + if (mode === 'Solo') { + if ( + await tx.employee.count({ + where: { isActive: true, id: { not: actorId } }, + }) + ) + blockers.push('OTHER_ACTIVE_EMPLOYEES'); + if ( + await tx.request.count({ + where: { + workflowState: { notIn: ['Approved', 'Rejected', 'Cancelled'] }, + }, + }) + ) + blockers.push('PENDING_REQUESTS'); + if ( + await tx.timeEntry.count({ + where: { status: 'Pending', voidedAt: null }, + }) + ) + blockers.push('PENDING_TIME_ENTRIES'); + if (await tx.terminal.count({ where: { isActive: true } })) + blockers.push('ACTIVE_TERMINALS'); + } + return blockers; + } + + async previewMode(actorId: string, mode: 'Team' | 'Solo') { + const blockers = await this.modeBlockers(actorId, mode, this.prisma); + return { allowed: !blockers.length, blockers }; + } + + async changeMode(actorId: string, dto: ChangeModeDto) { + await this.prisma + .$transaction( + async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + const before = await this.getSettings(tx); + if (before.revision !== dto.revision) + throw new ConflictException( + 'Settings changed; reload before switching mode', + ); + const blockers = await this.modeBlockers(actorId, dto.mode, tx); + if (blockers.length) + throw new ConflictException({ + message: 'Resolve pending work before switching mode', + blockers, + }); + if (before.mode === dto.mode) return; + const accountingDate = dateOnly(localDate()); + if (await this.hasAccountingHistory(tx, actorId, accountingDate)) + accountingDate.setUTCDate(accountingDate.getUTCDate() + 1); + const accountingEffectiveFrom = accountingDate + .toISOString() + .slice(0, 10); + await tx.installationSettings.upsert({ + where: { id: 1 }, + create: { mode: dto.mode, ownerEmployeeId: actorId, revision: 1 }, + update: { + mode: dto.mode, + ownerEmployeeId: actorId, + revision: { increment: 1 }, + setupCompleted: dto.mode === 'Team', + }, + }); + if (dto.mode === 'Solo') { + const projects = await tx.project.findMany({ + where: { + isActive: true, + OR: [{ customerId: null }, { customer: { isActive: true } }], + }, + select: { id: true }, + }); + await tx.projectAssignment.createMany({ + data: projects.map((project) => ({ + projectId: project.id, + employeeId: actorId, + })), + skipDuplicates: true, + }); + } + if ( + dto.mode === 'Solo' && + !(await tx.soloPolicy.count({ where: { employeeId: actorId } })) + ) { + const employee = await tx.employee.findUniqueOrThrow({ + where: { id: actorId }, + include: { workSchedule: true }, + }); + const allowance = await tx.employeeLeaveAllowance.findUnique({ + where: { + employeeId_year: { + employeeId: actorId, + year: new Date().getFullYear(), + }, + }, + }); + const isExistingWork = + (await tx.timeEntry.count({ where: { employeeId: actorId } })) > + 0; + await tx.soloPolicy.create({ + data: { + ...DEFAULT_SOLO_POLICY, + employeeId: actorId, + effectiveFrom: accountingDate, + ...(isExistingWork + ? { + targetEnabled: Number(employee.weeklyHours) > 0, + weeklyTargetMinutes: Math.round( + Number(employee.weeklyHours) * 60, + ), + leaveEnabled: + Number( + allowance?.baseDays ?? employee.annualLeaveDays, + ) > 0, + annualLeaveDays: + allowance?.baseDays ?? employee.annualLeaveDays, + carryOverDays: allowance?.carryOverDays ?? 0, + carryOverExpiresOn: allowance?.carryOverExpiresOn ?? null, + leaveAdjustmentDays: allowance?.adjustmentDays ?? 0, + leaveAdjustmentReason: + allowance?.adjustmentReason ?? null, + holidayCalendar: employee.holidayCalendar, + holidayDates: employee.holidayDates, + workingDays: employee.workSchedule?.workingDays ?? 31, + breakRules: employee.workSchedule?.breakRules ?? [], + } + : {}), + }, + }); + } + await tx.installationEvent.create({ + data: { + actorId, + action: 'ModeChanged', + before: jsonValue(before), + after: jsonValue({ + mode: dto.mode, + ownerEmployeeId: actorId, + accountingEffectiveFrom, + }), + }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + throw new ConflictException( + 'Installation changed concurrently; reload before switching mode', + ); + } + throw error; + }); + return this.getState(actorId); + } + + private async hasAccountingHistory( + tx: Prisma.TransactionClient, + actorId: string, + day: Date, + ): Promise { + const start = new Date(`${day.toISOString().slice(0, 10)}T00:00:00`); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const [entries, days, legacyAbsences, legacyRequests] = await Promise.all([ + tx.timeEntry.count({ + where: { + employeeId: actorId, + clockIn: { lt: end }, + clockOut: { gt: start }, + voidedAt: null, + status: { not: 'Rejected' }, + }, + }), + tx.personalDay.count({ + where: { + employeeId: actorId, + cancelledAt: null, + from: { lte: day }, + to: { gte: day }, + }, + }), + tx.absence.count({ + where: { employeeId: actorId, from: { lte: day }, to: { gte: day } }, + }), + tx.request.count({ + where: { + employeeId: actorId, + workflowState: 'Approved', + type: { in: ['Vacation', 'SpecialLeave'] }, + from: { lte: day }, + to: { gte: day }, + }, + }), + ]); + return Boolean(entries || days || legacyAbsences || legacyRequests); + } + + async days(actorId: string) { + await this.requireOwner(actorId); + return ( + await this.prisma.personalDay.findMany({ + where: { employeeId: actorId }, + orderBy: { from: 'desc' }, + }) + ).map(dayValue); + } + + async saveDay( + actorId: string, + dto: PersonalDayDto | EditPersonalDayDto, + id?: string, + ) { + const from = dateOnly(dto.from), + to = dateOnly(dto.to); + if (to < from || to.getTime() - from.getTime() > 366 * 86400000) + throw new BadRequestException('Invalid personal day range'); + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireOwner(actorId, tx); + const before = id + ? await tx.personalDay.findFirst({ + where: { id, employeeId: actorId, cancelledAt: null }, + }) + : null; + if (id && !before) throw new NotFoundException('Personal day not found'); + if (before && before.revision !== (dto as EditPersonalDayDto).revision) + throw new ConflictException( + 'Personal day changed; reload before editing', + ); + if ( + await tx.personalDay.count({ + where: { + employeeId: actorId, + cancelledAt: null, + from: { lte: to }, + to: { gte: from }, + ...(id ? { id: { not: id } } : {}), + }, + }) + ) { + throw new ConflictException('Personal days must not overlap'); + } + const oldDays = await tx.absence.count({ + where: { employeeId: actorId, from: { lte: to }, to: { gte: from } }, + }); + const oldRequests = await tx.request.count({ + where: { + employeeId: actorId, + workflowState: 'Approved', + type: { in: ['Vacation', 'SpecialLeave'] }, + from: { lte: to }, + to: { gte: from }, + }, + }); + if (oldDays || oldRequests) + throw new ConflictException( + 'This range overlaps existing historical time off', + ); + const data = { + employeeId: actorId, + kind: dto.kind, + from, + to, + note: dto.note ?? null, + halfDayStart: dto.halfDayStart ?? false, + halfDayEnd: dto.halfDayEnd ?? false, + }; + const after = before + ? await tx.personalDay.update({ + where: { id }, + data: { ...data, revision: { increment: 1 } }, + }) + : await tx.personalDay.create({ data }); + await tx.installationEvent.create({ + data: { + actorId, + action: before ? 'PersonalDayChanged' : 'PersonalDayCreated', + before: jsonValue(before), + after: jsonValue(after), + }, + }); + return dayValue(after); + }); + } + + async cancelDay(actorId: string, id: string, revision: number) { + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + await this.requireOwner(actorId, tx); + const before = await tx.personalDay.findFirst({ + where: { id, employeeId: actorId, cancelledAt: null }, + }); + if (!before) throw new NotFoundException('Personal day not found'); + if (before.revision !== revision) + throw new ConflictException( + 'Personal day changed; reload before cancelling', + ); + const after = await tx.personalDay.update({ + where: { id }, + data: { cancelledAt: new Date(), revision: { increment: 1 } }, + }); + await tx.installationEvent.create({ + data: { + actorId, + action: 'PersonalDayCancelled', + before: jsonValue(before), + after: jsonValue(after), + }, + }); + return dayValue(after); + }); + } + + async events(actorId: string) { + await this.requireOwner(actorId); + return this.prisma.installationEvent.findMany({ + where: { actorId }, + orderBy: { occurredAt: 'desc' }, + take: 200, + }); + } + + async dayAudit(actorId: string, id: string) { + await this.requireOwner(actorId); + if ( + !(await this.prisma.personalDay.findFirst({ + where: { id, employeeId: actorId }, + })) + ) + throw new NotFoundException('Personal day not found'); + return this.prisma.installationEvent.findMany({ + where: { + actorId, + action: { startsWith: 'PersonalDay' }, + OR: [ + { before: { path: ['id'], equals: id } }, + { after: { path: ['id'], equals: id } }, + ], + }, + orderBy: { occurredAt: 'asc' }, + }); + } +} diff --git a/apps/api/src/app/installation/personal-hints.service.ts b/apps/api/src/app/installation/personal-hints.service.ts new file mode 100644 index 0000000..1da5f41 --- /dev/null +++ b/apps/api/src/app/installation/personal-hints.service.ts @@ -0,0 +1,231 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + detectCoreTimeViolationsForDay, + holidayProviderForCalendar, + type CoreTimeWindow, +} from 'shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { + dateOnly, + DEFAULT_SOLO_POLICY, + InstallationService, + localDate, +} from './installation.service'; +import { parsePersonalWindows } from './personal-windows'; + +export class PersonalHintDto { + @ApiProperty() date!: string; + @ApiProperty({ + enum: [ + 'BeforeFrame', + 'AfterFrame', + 'LateArrival', + 'EarlyDeparture', + 'MidDayGap', + ], + }) + kind!: + | 'BeforeFrame' + | 'AfterFrame' + | 'LateArrival' + | 'EarlyDeparture' + | 'MidDayGap'; + @ApiProperty() boundary!: string; + @ApiProperty() deltaMinutes!: number; + @ApiPropertyOptional() windowLabel?: string; +} + +export class PersonalHintsDto { + @ApiProperty() enabled!: boolean; + @ApiProperty({ type: PersonalHintDto, isArray: true }) + hints!: PersonalHintDto[]; +} + +function localBoundary(date: string, hhmm: string): Date { + return new Date(`${date}T${hhmm}:00`); +} + +@Injectable() +export class PersonalHintsService { + constructor( + private readonly prisma: PrismaService, + private readonly installation: InstallationService, + ) {} + + async hints( + employeeId: string, + fromInput?: string, + toInput?: string, + ): Promise { + await this.installation.requireOwner(employeeId); + const today = localDate(); + const from = fromInput ?? `${today.slice(0, 7)}-01`; + const to = toInput ?? today; + const first = dateOnly(from), + last = dateOnly(to); + if (last < first || last.getTime() - first.getTime() > 366 * 86_400_000) + throw new BadRequestException('Select a period of at most one year'); + const start = localBoundary(from, '00:00'), + end = localBoundary(to, '00:00'); + end.setDate(end.getDate() + 1); + const [policies, personalDays, absences, leaveRequests, entries] = + await Promise.all([ + this.prisma.soloPolicy.findMany({ + where: { employeeId, effectiveFrom: { lte: last } }, + orderBy: [ + { effectiveFrom: 'asc' }, + { createdAt: 'asc' }, + { id: 'asc' }, + ], + }), + this.prisma.personalDay.findMany({ + where: { + employeeId, + cancelledAt: null, + from: { lte: last }, + to: { gte: first }, + }, + select: { from: true, to: true }, + }), + this.prisma.absence.findMany({ + where: { employeeId, from: { lte: last }, to: { gte: first } }, + select: { from: true, to: true }, + }), + this.prisma.request.findMany({ + where: { + employeeId, + type: { in: ['Vacation', 'SpecialLeave'] }, + workflowState: 'Approved', + from: { lte: last }, + to: { gte: first }, + }, + select: { from: true, to: true }, + }), + this.prisma.timeEntry.findMany({ + where: { + employeeId, + voidedAt: null, + status: { not: 'Rejected' }, + clockIn: { lt: end }, + clockOut: { not: null, gt: start }, + }, + select: { clockIn: true, clockOut: true }, + orderBy: { clockIn: 'asc' }, + }), + ]); + const excusedDays = [...personalDays, ...absences, ...leaveRequests]; + let enabled = false; + const hints: PersonalHintDto[] = []; + for ( + const day = new Date(first); + day <= last; + day.setUTCDate(day.getUTCDate() + 1) + ) { + const date = day.toISOString().slice(0, 10); + const policy = + policies.filter((candidate) => candidate.effectiveFrom <= day).at(-1) ?? + DEFAULT_SOLO_POLICY; + if (!policy.coreTimeHintsEnabled) continue; + enabled = true; + // Today can still be completed. Empty days and excused/holiday days do not + // invent missing attendance. A half day has no precise morning/afternoon + // location, so it cannot safely imply a missing core-time window either. + if ( + date >= today || + !(policy.workingDays & (1 << ((day.getUTCDay() + 6) % 7))) || + holidayProviderForCalendar( + policy.holidayCalendar, + policy.holidayDates, + ).isHoliday(day) || + excusedDays.some((period) => period.from <= day && period.to >= day) + ) + continue; + const dayStart = localBoundary(date, '00:00'); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayEnd.getDate() + 1); + const merged: Array<{ clockIn: Date; clockOut: Date }> = []; + for (const entry of entries) { + if (!entry.clockOut) continue; + const a = Math.max(dayStart.getTime(), entry.clockIn.getTime()); + const b = Math.min(dayEnd.getTime(), entry.clockOut.getTime()); + if (b <= a) continue; + const previous = merged.at(-1); + if (previous && a <= previous.clockOut.getTime()) + previous.clockOut = new Date( + Math.max(b, previous.clockOut.getTime()), + ); + else merged.push({ clockIn: new Date(a), clockOut: new Date(b) }); + } + if (!merged.length) continue; + const windows: CoreTimeWindow[] = parsePersonalWindows( + policy.coreTimes, + ).map((window) => { + const [startHour, startMinute] = window.start.split(':').map(Number); + const [endHour, endMinute] = window.end.split(':').map(Number); + return { + startHour, + startMinute, + endHour, + endMinute, + weekdays: window.weekdays, + label: window.label, + }; + }); + for (const hint of detectCoreTimeViolationsForDay( + merged, + windows, + dayStart, + )) { + if (hint.deltaMinutes > 0) hints.push({ date, ...hint }); + } + const frameStart = localBoundary(date, policy.frameStart); + const frameEnd = + policy.frameEnd === '23:59' + ? dayEnd + : localBoundary(date, policy.frameEnd); + const beforeMs = merged.reduce( + (sum, entry) => + sum + + Math.max( + 0, + Math.min(frameStart.getTime(), entry.clockOut.getTime()) - + entry.clockIn.getTime(), + ), + 0, + ); + const afterMs = merged.reduce( + (sum, entry) => + sum + + Math.max( + 0, + entry.clockOut.getTime() - + Math.max(frameEnd.getTime(), entry.clockIn.getTime()), + ), + 0, + ); + const boundary = `${policy.frameStart}–${policy.frameEnd}`; + if (beforeMs >= 60_000) + hints.push({ + date, + kind: 'BeforeFrame', + boundary, + deltaMinutes: Math.floor(beforeMs / 60_000), + }); + if (afterMs >= 60_000) + hints.push({ + date, + kind: 'AfterFrame', + boundary, + deltaMinutes: Math.floor(afterMs / 60_000), + }); + } + hints.sort( + (a, b) => + b.date.localeCompare(a.date) || + a.boundary.localeCompare(b.boundary) || + a.kind.localeCompare(b.kind), + ); + return { enabled, hints }; + } +} diff --git a/apps/api/src/app/installation/personal-summary.service.ts b/apps/api/src/app/installation/personal-summary.service.ts new file mode 100644 index 0000000..450fa04 --- /dev/null +++ b/apps/api/src/app/installation/personal-summary.service.ts @@ -0,0 +1,385 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Prisma, type SoloPolicy } from '@prisma/client'; +import { holidayProviderForCalendar } from 'shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { calculateCaptureSummaries } from '../time-entries/capture-summary'; +import { + dateOnly, + DEFAULT_SOLO_POLICY, + InstallationService, + localDate, +} from './installation.service'; + +const DAY_MS = 86_400_000; +type CalendarPolicy = Pick< + SoloPolicy, + 'workingDays' | 'holidayCalendar' | 'holidayDates' +>; +interface DayRange { + from: Date; + to: Date; + halfDayStart?: boolean; + halfDayEnd?: boolean; +} +interface ModeState { + mode: 'Solo' | 'Team'; + ownerEmployeeId?: string | null; +} + +function modeState(value: Prisma.JsonValue | null): ModeState | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + if (value.mode !== 'Solo' && value.mode !== 'Team') return null; + return { + mode: value.mode, + ownerEmployeeId: + typeof value.ownerEmployeeId === 'string' ? value.ownerEmployeeId : null, + }; +} + +/** Date-valued legacy requests, absences and personal days use UTC date keys. */ +function calendarDay(value: Date): Date { + return dateOnly(value.toISOString().slice(0, 10)); +} +function coverage(range: DayRange, day: Date): number { + const from = calendarDay(range.from), + to = calendarDay(range.to); + if (day < from || day > to) return 0; + return (range.halfDayStart && day.getTime() === from.getTime()) || + (range.halfDayEnd && day.getTime() === to.getTime()) + ? 0.5 + : 1; +} + +@Injectable() +export class PersonalSummaryService { + constructor( + private readonly prisma: PrismaService, + private readonly installation: InstallationService, + ) {} + + async summary(employeeId: string, fromInput?: string, toInput?: string) { + await this.installation.requireOwner(employeeId); + const today = localDate(); + const from = fromInput ?? `${today.slice(0, 4)}-01-01`, + to = toInput ?? today; + const first = dateOnly(from), + last = dateOnly(to); + if (last < first || (last.getTime() - first.getTime()) / DAY_MS + 1 > 366) + throw new BadRequestException('Select a period of at most 366 days'); + const start = new Date(`${from}T00:00:00`), + end = new Date(`${to}T00:00:00`); + end.setDate(end.getDate() + 1); + const year = last.getUTCFullYear(), + yearStart = dateOnly(`${year}-01-01`); + const beginning = new Date(Math.min(first.getTime(), yearStart.getTime())); + + return this.prisma.$transaction( + async (tx) => { + await this.installation.requireOwner(employeeId, tx); + const [ + policies, + days, + entries, + employee, + vacations, + absences, + modeEvents, + ] = await Promise.all([ + tx.soloPolicy.findMany({ + where: { employeeId }, + orderBy: [ + { effectiveFrom: 'asc' }, + { createdAt: 'asc' }, + { id: 'asc' }, + ], + }), + tx.personalDay.findMany({ + where: { + employeeId, + cancelledAt: null, + from: { lte: last }, + to: { gte: beginning }, + }, + }), + // Complete capture groups are required even outside the selected dates. + tx.timeEntry.findMany({ + where: { + employeeId, + voidedAt: null, + status: { not: 'Rejected' }, + clockOut: { not: null }, + }, + }), + tx.employee.findUniqueOrThrow({ + where: { id: employeeId }, + include: { workSchedule: true }, + }), + tx.request.findMany({ + where: { + employeeId, + type: 'Vacation', + workflowState: 'Approved', + cancelledAt: null, + from: { lt: new Date(last.getTime() + DAY_MS) }, + to: { gte: beginning }, + }, + }), + tx.absence.findMany({ + where: { employeeId, from: { lte: last }, to: { gte: beginning } }, + }), + tx.installationEvent.findMany({ + where: { action: 'ModeChanged' }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], + }), + ]); + + const policyOn = (day: Date) => + policies.filter((policy) => policy.effectiveFrom <= day).at(-1); + const modeOn = (day: Date): boolean => { + let state = modeEvents.length + ? modeState(modeEvents[0].before) + : null; + const key = day.toISOString().slice(0, 10); + // Live access changes immediately, while an explicit accounting date + // preserves a day that already contains recorded work or time off. + for (const event of modeEvents) { + const after = event.after; + const effective = + after && + typeof after === 'object' && + !Array.isArray(after) && + typeof after.accountingEffectiveFrom === 'string' && + /^\d{4}-\d{2}-\d{2}$/.test(after.accountingEffectiveFrom) + ? after.accountingEffectiveFrom + : localDate(event.occurredAt); + if (effective > key) continue; + state = modeState(event.after) ?? state; + } + return ( + !state || + (state.mode === 'Solo' && + (!state.ownerEmployeeId || state.ownerEmployeeId === employeeId)) + ); + }; + const legacyCalendar: CalendarPolicy = { + workingDays: employee.workSchedule?.workingDays ?? 31, + holidayCalendar: employee.holidayCalendar, + holidayDates: employee.holidayDates, + }; + const holidayCache = new Map< + string, + ReturnType + >(); + const workingDay = (day: Date, calendar: CalendarPolicy): boolean => { + const key = JSON.stringify([ + calendar.holidayCalendar, + calendar.holidayDates, + ]); + let provider = holidayCache.get(key); + if (!provider) { + provider = holidayProviderForCalendar( + calendar.holidayCalendar, + calendar.holidayDates, + ); + holidayCache.set(key, provider); + } + return ( + (calendar.workingDays & (1 << ((day.getUTCDay() + 6) % 7))) !== 0 && + !provider.isHoliday(day) + ); + }; + + // Legacy approved leave has a persisted total but no per-day calendar + // snapshot. Preserve that total, allocating it across its available + // working-date/half-day weights before clipping to the requested year. + const legacyVacation = new Map(); + for (const request of vacations) { + let weights: Array<{ day: Date; weight: number }> = []; + const calendarWeights: Array<{ day: Date; weight: number }> = []; + const requestEnd = calendarDay(request.to); + for ( + let day = calendarDay(request.from); + day <= requestEnd; + day = new Date(day.getTime() + DAY_MS) + ) { + const calendar = policyOn(day) ?? legacyCalendar; + calendarWeights.push({ day, weight: coverage(request, day) }); + if (workingDay(day, calendar)) + weights.push({ day, weight: coverage(request, day) }); + } + let totalWeight = weights.reduce( + (total, row) => total + row.weight, + 0, + ); + const recorded = Number(request.calculatedDays); + // A changed legacy calendar must never erase an already consumed + // entitlement. If it no longer supports the stored amount, distribute + // over the original date span rather than inventing a smaller total. + if (recorded > totalWeight) { + weights = calendarWeights; + totalWeight = weights.reduce((total, row) => total + row.weight, 0); + } + const scale = + totalWeight > 0 && recorded > 0 ? recorded / totalWeight : 1; + for (const row of weights) { + const key = row.day.toISOString().slice(0, 10); + // Old overlapping records do not consume the same day twice. + legacyVacation.set( + key, + Math.max( + legacyVacation.get(key) ?? 0, + Math.min(1, row.weight * scale), + ), + ); + } + } + + const summaries = calculateCaptureSummaries(entries); + const actualByDate = new Map(); + for (const entry of entries) { + if (!entry.clockOut) continue; + const duration = entry.clockOut.getTime() - entry.clockIn.getTime(); + if (duration <= 0) continue; + const rowEnd = Math.min(end.getTime(), entry.clockOut.getTime()); + let cursor = new Date( + Math.max(start.getTime(), entry.clockIn.getTime()), + ); + while (cursor.getTime() < rowEnd) { + const midnight = new Date( + cursor.getFullYear(), + cursor.getMonth(), + cursor.getDate() + 1, + ); + const fragmentEnd = Math.min(rowEnd, midnight.getTime()); + const net = + ((summaries.get(entry.id)?.netMinutes ?? 0) * + (fragmentEnd - cursor.getTime())) / + duration; + const key = localDate(cursor); + actualByDate.set(key, (actualByDate.get(key) ?? 0) + net); + cursor = new Date(fragmentEnd); + } + } + + let targetMinutes = 0, + targetActualMinutes = 0, + anyTarget = false; + const vacationByDate = new Map(); + for ( + let day = beginning; + day <= last; + day = new Date(day.getTime() + DAY_MS) + ) { + const key = day.toISOString().slice(0, 10), + policy = policyOn(day) ?? DEFAULT_SOLO_POLICY; + const isSolo = modeOn(day), + isWorkingDay = workingDay(day, policy); + const personalCoverage = Math.max( + 0, + ...days.map((entry) => coverage(entry, day)), + ); + const inheritedVacation = legacyVacation.get(key) ?? 0; + const inheritedExcused = Math.max( + 0, + ...absences + .filter((entry) => entry.kind !== 'Flextime') + .map((entry) => coverage(entry, day)), + ); + if (day >= first && policy.targetEnabled && isSolo) { + anyTarget = true; + targetActualMinutes += actualByDate.get(key) ?? 0; + const workdayCount = Array.from({ length: 7 }, (_, index) => + Number(Boolean(policy.workingDays & (1 << index))), + ).reduce((total, value) => total + value, 0); + const excused = Math.min( + 1, + Math.max(personalCoverage, inheritedVacation, inheritedExcused), + ); + if (isWorkingDay && workdayCount) + targetMinutes += + ((policy.weeklyTargetMinutes ?? 0) / workdayCount) * + (1 - excused); + } + if (day >= yearStart) { + const personalVacation = + policy.leaveEnabled && isSolo && isWorkingDay + ? Math.max( + 0, + ...days + .filter((entry) => entry.kind === 'Vacation') + .map((entry) => coverage(entry, day)), + ) + : 0; + vacationByDate.set( + key, + Math.max(inheritedVacation, personalVacation), + ); + } + } + + // Nothing from today's policy is projected backwards into an older period. + const allowancePolicy = policyOn(last); + const leaveEnabled = Boolean( + allowancePolicy?.leaveEnabled && modeOn(last), + ); + const annualBase = Number(allowancePolicy?.annualLeaveDays ?? 0); + const matchingYear = allowancePolicy?.leaveAllowanceYear === year; + const adjustment = matchingYear + ? Number(allowancePolicy?.leaveAdjustmentDays ?? 0) + : 0; + const configuredCarry = matchingYear + ? Number(allowancePolicy?.carryOverDays ?? 0) + : 0; + const expiry = matchingYear + ? (allowancePolicy?.carryOverExpiresOn ?? null) + : null; + const vacationDaysUsed = [...vacationByDate.values()].reduce( + (total, value) => total + value, + 0, + ); + const usedBeforeExpiry = [...vacationByDate].reduce( + (total, [key, value]) => + total + + (!expiry || key <= expiry.toISOString().slice(0, 10) ? value : 0), + 0, + ); + const carryUsed = Math.min(configuredCarry, usedBeforeExpiry); + const expiredCarry = + expiry && last > expiry ? configuredCarry - carryUsed : 0; + const carryEntitlement = configuredCarry - expiredCarry; + const allowance = annualBase + adjustment + carryEntitlement; + const actualMinutes = [...actualByDate.values()].reduce( + (total, value) => total + value, + 0, + ); + return { + from, + to, + timeZone: + process.env.TZ || + Intl.DateTimeFormat().resolvedOptions().timeZone || + 'UTC', + targetEnabled: anyTarget, + leaveEnabled, + actualMinutes, + targetActualMinutes: anyTarget ? targetActualMinutes : null, + targetMinutes: anyTarget ? targetMinutes : null, + overtimeMinutes: anyTarget + ? targetActualMinutes - targetMinutes + : null, + vacationAllowanceYear: year, + vacationDaysTotal: leaveEnabled ? allowance : null, + vacationDaysUsed: leaveEnabled ? vacationDaysUsed : null, + vacationDaysRemaining: leaveEnabled + ? allowance - vacationDaysUsed + : null, + vacationDaysCarryOver: leaveEnabled ? carryEntitlement : null, + vacationDaysCarryOverUsed: leaveEnabled ? carryUsed : null, + vacationDaysCarryOverExpired: leaveEnabled ? expiredCarry : null, + vacationDaysAdjustment: leaveEnabled ? adjustment : null, + }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); + } +} diff --git a/apps/api/src/app/installation/personal-windows.ts b/apps/api/src/app/installation/personal-windows.ts new file mode 100644 index 0000000..e1a0872 --- /dev/null +++ b/apps/api/src/app/installation/personal-windows.ts @@ -0,0 +1,61 @@ +export interface PersonalCoreWindow { + [key: string]: string | number | undefined; + start: string; + end: string; + weekdays: number; + label?: string; +} + +const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/; + +export function parsePersonalWindows(value: unknown): PersonalCoreWindow[] { + if (!Array.isArray(value) || value.length > 20) + throw new Error('Invalid personal core windows'); + const windows: PersonalCoreWindow[] = value.map((item) => { + if ( + !item || + typeof item.start !== 'string' || + !HHMM.test(item.start) || + typeof item.end !== 'string' || + !HHMM.test(item.end) || + item.start >= item.end || + !Number.isInteger(item.weekdays) || + item.weekdays < 1 || + item.weekdays > 127 || + (item.label !== undefined && + (typeof item.label !== 'string' || item.label.length > 100)) + ) + throw new Error('Invalid personal core window'); + return { + start: item.start, + end: item.end, + weekdays: item.weekdays, + ...(item.label ? { label: item.label.trim() } : {}), + }; + }); + for (let index = 0; index < windows.length; index++) { + if ( + windows + .slice(index + 1) + .some( + (other) => + (other.weekdays & windows[index].weekdays) !== 0 && + other.start < windows[index].end && + other.end > windows[index].start, + ) + ) + throw new Error('Personal core windows overlap'); + } + return windows; +} + +export function validatePersonalFrame( + start: string, + end: string, + windows: PersonalCoreWindow[], +): void { + if (!HHMM.test(start) || !HHMM.test(end) || start >= end) + throw new Error('Frame start must be before frame end'); + if (windows.some((window) => window.start < start || window.end > end)) + throw new Error('Core windows must be inside the personal frame'); +} diff --git a/apps/api/src/app/installation/solo-access.guard.ts b/apps/api/src/app/installation/solo-access.guard.ts new file mode 100644 index 0000000..93aa1c3 --- /dev/null +++ b/apps/api/src/app/installation/solo-access.guard.ts @@ -0,0 +1,48 @@ +import { + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { InstallationService } from './installation.service'; + +/** Old public/team endpoints cannot expose archived employee data in Solo. */ +@Injectable() +export class SoloAccessGuard extends AuthGuard('jwt') { + constructor(private readonly installation: InstallationService) { + super(); + } + + override async canActivate(context: ExecutionContext): Promise { + if (context.getType() !== 'http' || !(await this.installation.isSolo())) + return true; + const req = context.switchToHttp().getRequest<{ + path?: string; + url: string; + user?: JwtUser; + method: string; + }>(); + const path = (req.path ?? req.url.split('?')[0]).replace( + /^\/api(?=\/)/, + '', + ); + if ( + req.method === 'OPTIONS' || + ['/health', '/auth/login', '/auth/refresh'].includes(path) + ) + return true; + await super.canActivate(context); + if (!req.user) throw new ForbiddenException('Solo owner access required'); + await this.installation.requireOwner(req.user.id); + const allowed = + /^\/(auth|installation|timeentries|projects|customers)(\/|$)/.test( + path, + ) || /^\/reports\/solo(\.csv)?$/.test(path); + if (!allowed) + throw new ForbiddenException( + 'This team capability is disabled in Solo mode', + ); + return true; + } +} diff --git a/apps/api/src/app/projects/projects.controller.ts b/apps/api/src/app/projects/projects.controller.ts index a9bf3ee..0a07075 100644 --- a/apps/api/src/app/projects/projects.controller.ts +++ b/apps/api/src/app/projects/projects.controller.ts @@ -12,28 +12,40 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; import { ProjectsService } from './projects.service'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { SoloProjectAccessGuard } from './solo-project-access.guard'; import { UpsertProjectDto, UpsertServiceOrderDto, - type BookableProjectDto, + BookableProjectDto, type ProjectAssignmentDto, - type ProjectDto, + ProjectDto, type ProjectReportDto, - type ServiceOrderDto, + ServiceOrderDto, } from './projects.dto'; @ApiTags('projects') @Controller('projects') +@UseGuards(SoloProjectAccessGuard) export class ProjectsController { constructor(private readonly projects: ProjectsService) {} @Get() - list(@Query('includeInactive') includeInactive?: string): Promise { + @ApiOkResponse({ type: [ProjectDto] }) + list( + @Query('includeInactive') includeInactive?: string, + ): Promise { return this.projects.list(includeInactive === 'true'); } @@ -48,6 +60,7 @@ export class ProjectsController { } @Get('bookable') + @ApiOkResponse({ type: [BookableProjectDto] }) listBookable( @Query('employeeId', new ParseUUIDPipe()) employeeId: string, ): Promise { @@ -55,6 +68,7 @@ export class ProjectsController { } @Get(':id') + @ApiOkResponse({ type: ProjectDto }) get(@Param('id', new ParseUUIDPipe()) id: string): Promise { return this.projects.getById(id); } @@ -76,22 +90,28 @@ export class ProjectsController { } @Post() + @ApiCreatedResponse({ type: ProjectDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Manager', 'HRAdmin') - create(@Body() dto: UpsertProjectDto): Promise { - return this.projects.create(dto); + create( + @Body() dto: UpsertProjectDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.projects.create(dto, user); } @Put(':id') + @ApiOkResponse({ type: ProjectDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Manager', 'HRAdmin') update( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: UpsertProjectDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.projects.update(id, dto); + return this.projects.update(id, dto, user); } @Delete(':id') @@ -99,22 +119,28 @@ export class ProjectsController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Manager', 'HRAdmin') @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { - return this.projects.remove(id); + remove( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.projects.remove(id, user); } @Post(':id/service-orders') + @ApiCreatedResponse({ type: ServiceOrderDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Manager', 'HRAdmin') createServiceOrder( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: UpsertServiceOrderDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.projects.createServiceOrder(id, dto); + return this.projects.createServiceOrder(id, dto, user); } @Put(':id/service-orders/:orderId') + @ApiOkResponse({ type: ServiceOrderDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Manager', 'HRAdmin') @@ -122,8 +148,9 @@ export class ProjectsController { @Param('id', new ParseUUIDPipe()) id: string, @Param('orderId', new ParseUUIDPipe()) orderId: string, @Body() dto: UpsertServiceOrderDto, + @CurrentUser() user: JwtUser, ): Promise { - return this.projects.updateServiceOrder(id, orderId, dto); + return this.projects.updateServiceOrder(id, orderId, dto, user); } @Delete(':id/service-orders/:orderId') @@ -134,8 +161,9 @@ export class ProjectsController { removeServiceOrder( @Param('id', new ParseUUIDPipe()) id: string, @Param('orderId', new ParseUUIDPipe()) orderId: string, + @CurrentUser() user: JwtUser, ): Promise { - return this.projects.removeServiceOrder(id, orderId); + return this.projects.removeServiceOrder(id, orderId, user); } @Put(':id/assignments/:employeeId') @@ -146,8 +174,9 @@ export class ProjectsController { assign( @Param('id', new ParseUUIDPipe()) id: string, @Param('employeeId', new ParseUUIDPipe()) employeeId: string, + @CurrentUser() user: JwtUser, ): Promise { - return this.projects.assign(id, employeeId); + return this.projects.assign(id, employeeId, user); } @Delete(':id/assignments/:employeeId') @@ -158,7 +187,8 @@ export class ProjectsController { unassign( @Param('id', new ParseUUIDPipe()) id: string, @Param('employeeId', new ParseUUIDPipe()) employeeId: string, + @CurrentUser() user: JwtUser, ): Promise { - return this.projects.unassign(id, employeeId); + return this.projects.unassign(id, employeeId, user); } } diff --git a/apps/api/src/app/projects/projects.dto.ts b/apps/api/src/app/projects/projects.dto.ts index 947b5bf..29d09d9 100644 --- a/apps/api/src/app/projects/projects.dto.ts +++ b/apps/api/src/app/projects/projects.dto.ts @@ -4,6 +4,7 @@ import { IsNumber, IsOptional, IsString, + IsUUID, MaxLength, Min, } from 'class-validator'; @@ -37,6 +38,16 @@ export class UpsertProjectDto { @IsNumber() @Min(0) planHours?: number | null; + + @ApiPropertyOptional({ format: 'uuid', nullable: true }) + @IsOptional() + @IsUUID() + customerId?: string | null; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + defaultBillable?: boolean; } export class UpsertServiceOrderDto { @@ -61,31 +72,70 @@ export class UpsertServiceOrderDto { @IsNumber() @Min(0) planHours?: number | null; + + @ApiPropertyOptional({ + nullable: true, + description: 'Null inherits the project default.', + }) + @IsOptional() + @IsBoolean() + defaultBillable?: boolean | null; } -export interface ServiceOrderDto { - id: string; - projectId: string; - orderNo: string; - title: string; - isActive: boolean; - planHours: number | null; +export class ServiceOrderDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty({ format: 'uuid' }) + projectId!: string; + @ApiProperty() + orderNo!: string; + @ApiProperty() + title!: string; + @ApiProperty() + isActive!: boolean; + @ApiProperty({ type: Number, nullable: true }) + planHours!: number | null; /** Gross minutes booked onto this order (closed, non-rejected entries). */ - bookedMinutes: number; + @ApiProperty() + bookedMinutes!: number; + @ApiProperty({ type: Boolean, nullable: true }) + defaultBillable!: boolean | null; + /** Exact net minutes in the Solo owner's report; absent in Team mode. */ + @ApiPropertyOptional() + bookedNetMinutes?: number; } -export interface ProjectDto { - id: string; - code: string; - name: string; - description: string | null; - isActive: boolean; - planHours: number | null; +export class ProjectDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty() + code!: string; + @ApiProperty() + name!: string; + @ApiProperty({ type: String, nullable: true }) + description!: string | null; + @ApiProperty() + isActive!: boolean; + @ApiProperty({ type: Number, nullable: true }) + planHours!: number | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + customerId!: string | null; + @ApiProperty({ type: String, nullable: true }) + customerName!: string | null; + @ApiProperty() + defaultBillable!: boolean; + /** Exact net minutes in the Solo owner's report; absent in Team mode. */ + @ApiPropertyOptional() + bookedNetMinutes?: number; /** Gross minutes booked onto the project incl. order-less entries. */ - bookedMinutes: number; - serviceOrders: ServiceOrderDto[]; - assignedEmployeeCount: number; - updatedAt: string; + @ApiProperty() + bookedMinutes!: number; + @ApiProperty({ type: [ServiceOrderDto] }) + serviceOrders!: ServiceOrderDto[]; + @ApiProperty() + assignedEmployeeCount!: number; + @ApiProperty({ format: 'date-time' }) + updatedAt!: string; } export interface ProjectAssignmentDto { @@ -93,19 +143,34 @@ export interface ProjectAssignmentDto { projectId: string; } -export interface BookableServiceOrderDto { - id: string; - orderNo: string; - title: string; +export class BookableServiceOrderDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty() + orderNo!: string; + @ApiProperty() + title!: string; + @ApiProperty({ type: Boolean, nullable: true }) + defaultBillable!: boolean | null; } /** Slim shape for the booking selector: active projects assigned to the employee. */ -export interface BookableProjectDto { - id: string; - code: string; - name: string; +export class BookableProjectDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty() + code!: string; + @ApiProperty() + name!: string; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + customerId!: string | null; + @ApiProperty({ type: String, nullable: true }) + customerName!: string | null; + @ApiProperty() + defaultBillable!: boolean; /** Active service orders — when non-empty, one MUST be chosen on booking. */ - serviceOrders: BookableServiceOrderDto[]; + @ApiProperty({ type: [BookableServiceOrderDto] }) + serviceOrders!: BookableServiceOrderDto[]; } export interface ProjectReportRow { @@ -131,6 +196,8 @@ export interface ProjectReportDto { export interface ProjectIstStats { totalMinutes: number; byOrder: ReadonlyMap; + netMinutes?: number; + netByOrder?: ReadonlyMap; } export const EMPTY_IST_STATS: ProjectIstStats = { @@ -145,6 +212,7 @@ function decimalToNumber(value: unknown): number | null { export function toServiceOrderDto( o: ServiceOrder, bookedMinutes: number, + bookedNetMinutes?: number, ): ServiceOrderDto { return { id: o.id, @@ -154,11 +222,16 @@ export function toServiceOrderDto( isActive: o.isActive, planHours: decimalToNumber(o.planHours), bookedMinutes, + defaultBillable: o.defaultBillable, + ...(bookedNetMinutes === undefined ? {} : { bookedNetMinutes }), }; } export function toProjectDto( - p: Project & { serviceOrders: ServiceOrder[] }, + p: Project & { + serviceOrders: ServiceOrder[]; + customer?: { name: string } | null; + }, assignedEmployeeCount: number, stats: ProjectIstStats = EMPTY_IST_STATS, ): ProjectDto { @@ -169,9 +242,20 @@ export function toProjectDto( description: p.description, isActive: p.isActive, planHours: decimalToNumber(p.planHours), + customerId: p.customerId, + customerName: p.customer?.name ?? null, + defaultBillable: p.defaultBillable, bookedMinutes: stats.totalMinutes, + ...(stats.netMinutes === undefined + ? {} + : { bookedNetMinutes: stats.netMinutes }), serviceOrders: p.serviceOrders.map((o) => - toServiceOrderDto(o, stats.byOrder.get(o.id) ?? 0), + toServiceOrderDto( + o, + stats.byOrder.get(o.id) ?? 0, + stats.netByOrder?.get(o.id) ?? + (stats.netMinutes === undefined ? undefined : 0), + ), ), assignedEmployeeCount, updatedAt: p.updatedAt.toISOString(), diff --git a/apps/api/src/app/projects/projects.module.ts b/apps/api/src/app/projects/projects.module.ts index 5ccedcd..9a2641a 100644 --- a/apps/api/src/app/projects/projects.module.ts +++ b/apps/api/src/app/projects/projects.module.ts @@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; +import { SoloProjectAccessGuard } from './solo-project-access.guard'; // Global so TimeEntriesService can validate project bookings without an // explicit module import (same pattern as WorkSchedulesModule). @@ -9,7 +10,7 @@ import { ProjectsService } from './projects.service'; @Module({ imports: [AuthModule], controllers: [ProjectsController], - providers: [ProjectsService], + providers: [ProjectsService, SoloProjectAccessGuard], exports: [ProjectsService], }) export class ProjectsModule {} diff --git a/apps/api/src/app/projects/projects.service.ts b/apps/api/src/app/projects/projects.service.ts index 8e9baaa..6ec5bb1 100644 --- a/apps/api/src/app/projects/projects.service.ts +++ b/apps/api/src/app/projects/projects.service.ts @@ -9,6 +9,12 @@ import { Prisma, type ServiceOrder } from '@prisma/client'; import { summarize } from 'shared'; import { PrismaService } from '../prisma/prisma.service'; import { EventsGateway } from '../events/events.gateway'; +import { + INSTALLATION_LOCK, + InstallationService, +} from '../installation/installation.service'; +import { calculateCaptureSummaries } from '../time-entries/capture-summary'; +import type { JwtUser } from '../auth/jwt.strategy'; import { EMPTY_IST_STATS, toProjectDto, @@ -40,6 +46,7 @@ export class ProjectsService { constructor( private readonly prisma: PrismaService, private readonly events: EventsGateway, + private readonly installation: InstallationService, ) {} async list(includeInactive: boolean): Promise { @@ -48,6 +55,7 @@ export class ProjectsService { where: includeInactive ? undefined : { isActive: true }, orderBy: { code: 'asc' }, include: { + customer: { select: { name: true } }, serviceOrders: { orderBy: { orderNo: 'asc' } }, _count: { select: { assignments: true } }, }, @@ -69,20 +77,39 @@ export class ProjectsService { ); } - async create(dto: UpsertProjectDto): Promise { + async create(dto: UpsertProjectDto, actor: JwtUser): Promise { + let solo = false; try { - const created = await this.prisma.project.create({ - data: { - code: dto.code, - name: dto.name, - description: dto.description ?? null, - isActive: dto.isActive ?? true, - planHours: dto.planHours ?? null, - }, - include: { serviceOrders: true }, + const created = await this.mutate(actor, async (tx, isSolo) => { + solo = isSolo; + if (dto.customerId) await this.assertCustomerActive(tx, dto.customerId); + return tx.project.create({ + data: { + code: dto.code, + name: dto.name, + description: dto.description ?? null, + isActive: dto.isActive ?? true, + planHours: dto.planHours ?? null, + customerId: dto.customerId ?? null, + defaultBillable: dto.defaultBillable ?? false, + assignments: solo + ? { create: { employeeId: actor.id } } + : undefined, + }, + include: { + serviceOrders: true, + customer: { select: { name: true } }, + }, + }); }); this.broadcast(created.id); - return toProjectDto(created, 0); + return toProjectDto( + created, + solo ? 1 : 0, + solo + ? { ...EMPTY_IST_STATS, netMinutes: 0, netByOrder: new Map() } + : EMPTY_IST_STATS, + ); } catch (err) { if (isUniqueViolation(err)) { throw new ConflictException( @@ -93,32 +120,69 @@ export class ProjectsService { } } - async update(id: string, dto: UpsertProjectDto): Promise { - const existing = await this.findOrThrow(id); + async update( + id: string, + dto: UpsertProjectDto, + actor: JwtUser, + ): Promise { // Reducing (or introducing) the project plan below the current sum of // service-order plans would silently break the invariant — reject. - if (dto.planHours !== null && dto.planHours !== undefined) { - const ordersTotal = sumPlanHours(existing.serviceOrders); - if (ordersTotal > dto.planHours) { - throw new ConflictException( - `Project plan of ${dto.planHours} h is below the service-order total of ${ordersTotal} h`, - ); - } - } try { - const updated = await this.prisma.project.update({ - where: { id }, - data: { - code: dto.code, - name: dto.name, - description: dto.description ?? null, - isActive: dto.isActive ?? true, - planHours: dto.planHours ?? null, - }, - include: { - serviceOrders: { orderBy: { orderNo: 'asc' } }, - _count: { select: { assignments: true } }, - }, + const updated = await this.mutate(actor, async (tx) => { + const existing = await this.findOrThrow(id, tx); + if (dto.planHours !== null && dto.planHours !== undefined) { + const ordersTotal = sumPlanHours(existing.serviceOrders); + if (ordersTotal > dto.planHours) + throw new ConflictException( + `Project plan of ${dto.planHours} h is below the service-order total of ${ordersTotal} h`, + ); + } + if (dto.customerId && dto.customerId !== existing.customerId) + await this.assertCustomerActive(tx, dto.customerId); + await tx.$queryRaw`SELECT "id" FROM "Project" WHERE "id" = ${id}::uuid FOR UPDATE`; + const current = await tx.project.findUniqueOrThrow({ where: { id } }); + if ( + dto.customerId !== undefined && + dto.customerId !== current.customerId + ) { + if (await tx.timeEntry.count({ where: { projectId: id } })) + throw new ConflictException( + 'A booked project cannot change customer; create a new project instead', + ); + } + if ( + current.isActive && + dto.isActive === false && + (await tx.timeEntry.count({ + where: { + projectId: id, + clockOut: null, + voidedAt: null, + status: { not: 'Rejected' }, + }, + })) + ) { + throw new ConflictException( + 'Finish or reassign the running timer before archiving this project', + ); + } + return tx.project.update({ + where: { id }, + data: { + code: dto.code, + name: dto.name, + description: dto.description ?? null, + isActive: dto.isActive ?? true, + planHours: dto.planHours ?? null, + customerId: dto.customerId, + defaultBillable: dto.defaultBillable, + }, + include: { + customer: { select: { name: true } }, + serviceOrders: { orderBy: { orderNo: 'asc' } }, + _count: { select: { assignments: true } }, + }, + }); }); this.broadcast(id); const stats = await this.loadIstStats(id); @@ -137,39 +201,45 @@ export class ProjectsService { } } - async remove(id: string): Promise { - await this.findOrThrow(id); - const bookedEntries = await this.prisma.timeEntry.count({ - where: { projectId: id }, + async remove(id: string, actor: JwtUser): Promise { + await this.mutate(actor, async (tx) => { + await this.findOrThrow(id, tx); + const bookedEntries = await tx.timeEntry.count({ + where: { projectId: id }, + }); + if (bookedEntries > 0) { + throw new ConflictException( + 'Project has booked time entries and cannot be deleted — deactivate it instead', + ); + } + await tx.project.delete({ where: { id } }); }); - if (bookedEntries > 0) { - throw new ConflictException( - 'Project has booked time entries and cannot be deleted — deactivate it instead', - ); - } - await this.prisma.project.delete({ where: { id } }); this.broadcast(id); } async createServiceOrder( projectId: string, dto: UpsertServiceOrderDto, + actor: JwtUser, ): Promise { - const project = await this.findOrThrow(projectId); - this.assertOrderPlanFits( - project, - project.serviceOrders, - dto.planHours ?? null, - ); try { - const created = await this.prisma.serviceOrder.create({ - data: { - projectId, - orderNo: dto.orderNo, - title: dto.title, - isActive: dto.isActive ?? true, - planHours: dto.planHours ?? null, - }, + const created = await this.mutate(actor, async (tx) => { + const project = await this.findOrThrow(projectId, tx); + this.assertOrderPlanFits( + project, + project.serviceOrders, + dto.planHours ?? null, + ); + return tx.serviceOrder.create({ + data: { + projectId, + orderNo: dto.orderNo, + title: dto.title, + isActive: dto.isActive ?? true, + planHours: dto.planHours ?? null, + defaultBillable: dto.defaultBillable ?? null, + }, + }); }); this.broadcast(projectId); return toServiceOrderDto(created, 0); @@ -187,28 +257,52 @@ export class ProjectsService { projectId: string, orderId: string, dto: UpsertServiceOrderDto, + actor: JwtUser, ): Promise { - await this.findServiceOrderOrThrow(projectId, orderId); - const project = await this.findOrThrow(projectId); - this.assertOrderPlanFits( - project, - project.serviceOrders.filter((o) => o.id !== orderId), - dto.planHours ?? null, - ); try { - const updated = await this.prisma.serviceOrder.update({ - where: { id: orderId }, - data: { - orderNo: dto.orderNo, - title: dto.title, - isActive: dto.isActive ?? true, - planHours: dto.planHours ?? null, - }, + const updated = await this.mutate(actor, async (tx) => { + await this.findServiceOrderOrThrow(projectId, orderId, tx); + const project = await this.findOrThrow(projectId, tx); + this.assertOrderPlanFits( + project, + project.serviceOrders.filter((order) => order.id !== orderId), + dto.planHours ?? null, + ); + await tx.$queryRaw`SELECT "id" FROM "ServiceOrder" WHERE "id" = ${orderId}::uuid FOR UPDATE`; + if ( + dto.isActive === false && + (await tx.timeEntry.count({ + where: { + serviceOrderId: orderId, + clockOut: null, + voidedAt: null, + status: { not: 'Rejected' }, + }, + })) + ) { + throw new ConflictException( + 'Finish or reassign the running timer before archiving this service order', + ); + } + return tx.serviceOrder.update({ + where: { id: orderId }, + data: { + orderNo: dto.orderNo, + title: dto.title, + isActive: dto.isActive ?? true, + planHours: dto.planHours ?? null, + defaultBillable: dto.defaultBillable, + }, + }); }); this.broadcast(projectId); const stats = await this.loadIstStats(projectId); const booked = stats.get(projectId)?.byOrder.get(orderId) ?? 0; - return toServiceOrderDto(updated, booked); + return toServiceOrderDto( + updated, + booked, + stats.get(projectId)?.netByOrder?.get(orderId), + ); } catch (err) { if (isUniqueViolation(err)) { throw new ConflictException( @@ -219,48 +313,79 @@ export class ProjectsService { } } - async removeServiceOrder(projectId: string, orderId: string): Promise { - await this.findServiceOrderOrThrow(projectId, orderId); - const bookedEntries = await this.prisma.timeEntry.count({ - where: { serviceOrderId: orderId }, + async removeServiceOrder( + projectId: string, + orderId: string, + actor: JwtUser, + ): Promise { + await this.mutate(actor, async (tx) => { + await this.findServiceOrderOrThrow(projectId, orderId, tx); + const bookedEntries = await tx.timeEntry.count({ + where: { serviceOrderId: orderId }, + }); + if (bookedEntries > 0) { + throw new ConflictException( + 'Service order has booked time entries and cannot be deleted — deactivate it instead', + ); + } + await tx.serviceOrder.delete({ where: { id: orderId } }); }); - if (bookedEntries > 0) { - throw new ConflictException( - 'Service order has booked time entries and cannot be deleted — deactivate it instead', - ); - } - await this.prisma.serviceOrder.delete({ where: { id: orderId } }); this.broadcast(projectId); } /** Idempotent: assigning an already-assigned employee is a no-op success. */ - async assign(projectId: string, employeeId: string): Promise { - await this.findOrThrow(projectId); - const employee = await this.prisma.employee.findUnique({ - where: { id: employeeId }, - }); - if (!employee) - throw new NotFoundException(`Employee ${employeeId} not found`); - await this.prisma.projectAssignment.upsert({ - where: { employeeId_projectId: { employeeId, projectId } }, - create: { employeeId, projectId }, - update: {}, + async assign( + projectId: string, + employeeId: string, + actor: JwtUser, + ): Promise { + await this.mutate(actor, async (tx, solo) => { + if (solo && employeeId !== actor.id) + throw new ForbiddenException( + 'Solo projects can only be assigned to the owner', + ); + await this.findOrThrow(projectId, tx); + const employee = await tx.employee.findUnique({ + where: { id: employeeId }, + }); + if (!employee) + throw new NotFoundException(`Employee ${employeeId} not found`); + await tx.projectAssignment.upsert({ + where: { employeeId_projectId: { employeeId, projectId } }, + create: { employeeId, projectId }, + update: {}, + }); }); this.broadcast(projectId); } /** Idempotent: removing a non-existent assignment is a no-op success. */ - async unassign(projectId: string, employeeId: string): Promise { - await this.findOrThrow(projectId); - await this.prisma.projectAssignment.deleteMany({ - where: { employeeId, projectId }, + async unassign( + projectId: string, + employeeId: string, + actor: JwtUser, + ): Promise { + await this.mutate(actor, async (tx, solo) => { + if (solo) + throw new ConflictException( + 'The Solo owner keeps access to their projects', + ); + await this.findOrThrow(projectId, tx); + await tx.projectAssignment.deleteMany({ + where: { employeeId, projectId }, + }); }); this.broadcast(projectId); } /** Full matrix data: one row per existing employee↔project assignment. */ async listAssignments(): Promise { + const settings = await this.installation.getSettings(); const rows = await this.prisma.projectAssignment.findMany({ + where: + settings.mode === 'Solo' + ? { employeeId: settings.ownerEmployeeId ?? undefined } + : undefined, select: { employeeId: true, projectId: true }, }); return rows; @@ -269,20 +394,35 @@ export class ProjectsService { /** Active projects the employee is assigned to — the booking selector source. */ async listBookable(employeeId: string): Promise { const rows = await this.prisma.project.findMany({ - where: { isActive: true, assignments: { some: { employeeId } } }, + where: { + isActive: true, + assignments: { some: { employeeId } }, + OR: [{ customerId: null }, { customer: { isActive: true } }], + }, orderBy: { code: 'asc' }, select: { id: true, code: true, name: true, + customerId: true, + customer: { select: { name: true } }, + defaultBillable: true, serviceOrders: { where: { isActive: true }, orderBy: { orderNo: 'asc' }, - select: { id: true, orderNo: true, title: true }, + select: { + id: true, + orderNo: true, + title: true, + defaultBillable: true, + }, }, }, }); - return rows; + return rows.map(({ customer, ...row }) => ({ + ...row, + customerName: customer?.name ?? null, + })); } /** @@ -293,11 +433,14 @@ export class ProjectsService { async assertBookable(employeeId: string, projectId: string): Promise { const project = await this.prisma.project.findUnique({ where: { id: projectId }, + include: { customer: { select: { isActive: true } } }, }); if (!project) throw new NotFoundException(`Project ${projectId} not found`); if (!project.isActive) { throw new BadRequestException(`Project "${project.code}" is inactive`); } + if (project.customer && !project.customer.isActive) + throw new BadRequestException('The project customer is archived'); const assignment = await this.prisma.projectAssignment.findUnique({ where: { employeeId_projectId: { employeeId, projectId } }, }); @@ -352,10 +495,16 @@ export class ProjectsService { /** Customer-facing activity report: closed, non-rejected project bookings. */ async report(id: string, from?: Date, to?: Date): Promise { const project = await this.findOrThrow(id); + const settings = await this.installation.getSettings(); const where: Prisma.TimeEntryWhereInput = { projectId: id, + employeeId: + settings.mode === 'Solo' + ? (settings.ownerEmployeeId ?? undefined) + : undefined, clockOut: { not: null }, status: { not: 'Rejected' }, + voidedAt: null, }; if (from || to) { where.clockIn = {}; @@ -395,6 +544,8 @@ export class ProjectsService { private async loadIstStats( projectId?: string, ): Promise> { + if (await this.installation.isSolo()) + return this.loadSoloIstStats(projectId); const rows = projectId ? await this.prisma.$queryRaw` SELECT "projectId", "serviceOrderId", @@ -402,6 +553,7 @@ export class ProjectsService { FROM "TimeEntry" WHERE "projectId" = ${projectId}::uuid AND "clockOut" IS NOT NULL + AND "voidedAt" IS NULL AND "status" <> 'Rejected'::"EntryStatus" GROUP BY "projectId", "serviceOrderId" ` @@ -411,6 +563,7 @@ export class ProjectsService { FROM "TimeEntry" WHERE "projectId" IS NOT NULL AND "clockOut" IS NOT NULL + AND "voidedAt" IS NULL AND "status" <> 'Rejected'::"EntryStatus" GROUP BY "projectId", "serviceOrderId" `; @@ -431,6 +584,73 @@ export class ProjectsService { return map; } + /** Load complete capture groups before narrowing to a project. */ + private async loadSoloIstStats( + projectId?: string, + ): Promise> { + const settings = await this.installation.getSettings(); + const ownerId = settings.ownerEmployeeId; + const projects = await this.prisma.project.findMany({ + where: projectId ? { id: projectId } : undefined, + select: { id: true }, + }); + const map = new Map( + projects.map((project) => [ + project.id, + { + totalMinutes: 0, + byOrder: new Map(), + netMinutes: 0, + netByOrder: new Map(), + }, + ]), + ); + if (!ownerId) return map; + const entries = await this.prisma.timeEntry.findMany({ + where: { + employeeId: ownerId, + clockOut: { not: null }, + status: { not: 'Rejected' }, + voidedAt: null, + }, + }); + const summaries = calculateCaptureSummaries(entries); + for (const entry of entries) { + if (!entry.projectId) continue; + const stats = map.get(entry.projectId); + const summary = summaries.get(entry.id); + if (!stats || !summary) continue; + stats.totalMinutes += summary.grossMinutes; + stats.netMinutes = (stats.netMinutes ?? 0) + summary.netMinutes; + if (entry.serviceOrderId) { + const grossByOrder = stats.byOrder as Map; + const netByOrder = stats.netByOrder as Map; + grossByOrder.set( + entry.serviceOrderId, + (grossByOrder.get(entry.serviceOrderId) ?? 0) + summary.grossMinutes, + ); + netByOrder.set( + entry.serviceOrderId, + (netByOrder.get(entry.serviceOrderId) ?? 0) + summary.netMinutes, + ); + } + } + return map; + } + + private async assertCustomerActive( + tx: Prisma.TransactionClient, + customerId: string, + ): Promise { + await tx.$queryRaw`SELECT "id" FROM "Customer" WHERE "id" = ${customerId}::uuid FOR UPDATE`; + const customer = await tx.customer.findUnique({ + where: { id: customerId }, + }); + if (!customer) throw new NotFoundException('Customer not found'); + if (!customer.isActive) + throw new BadRequestException('The customer is archived'); + } + /** Σ order plans (incl. a candidate value) must not exceed the project plan. */ private assertOrderPlanFits( project: { code: string; planHours: unknown }, @@ -451,10 +671,38 @@ export class ProjectsService { this.events.broadcast('project:changed', { projectId }); } - private async findOrThrow(id: string) { - const row = await this.prisma.project.findUnique({ + /** Serialize writes with mode/account changes, then re-check live authority. */ + private async mutate( + actor: JwtUser, + action: (tx: Prisma.TransactionClient, solo: boolean) => Promise, + ): Promise { + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${INSTALLATION_LOCK})`; + const employee = await tx.employee.findUnique({ + where: { id: actor.id }, + }); + if ( + !employee?.isActive || + !['Manager', 'HRAdmin'].includes(employee.role) || + (actor.authVersion ?? 0) !== employee.authVersion + ) + throw new ForbiddenException( + 'Project management session is no longer authorized', + ); + const solo = (await this.installation.getSettings(tx)).mode === 'Solo'; + if (solo) await this.installation.requireOwner(actor.id, tx); + return action(tx, solo); + }); + } + + private async findOrThrow( + id: string, + tx: Prisma.TransactionClient = this.prisma, + ) { + const row = await tx.project.findUnique({ where: { id }, include: { + customer: { select: { name: true } }, serviceOrders: { orderBy: { orderNo: 'asc' } }, _count: { select: { assignments: true } }, }, @@ -463,8 +711,12 @@ export class ProjectsService { return row; } - private async findServiceOrderOrThrow(projectId: string, orderId: string) { - const row = await this.prisma.serviceOrder.findFirst({ + private async findServiceOrderOrThrow( + projectId: string, + orderId: string, + tx: Prisma.TransactionClient = this.prisma, + ) { + const row = await tx.serviceOrder.findFirst({ where: { id: orderId, projectId }, }); if (!row) { diff --git a/apps/api/src/app/projects/solo-project-access.guard.ts b/apps/api/src/app/projects/solo-project-access.guard.ts new file mode 100644 index 0000000..40895b1 --- /dev/null +++ b/apps/api/src/app/projects/solo-project-access.guard.ts @@ -0,0 +1,33 @@ +import { + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { InstallationService } from '../installation/installation.service'; + +/** Preserve legacy Team reads while enforcing authenticated Solo ownership. */ +@Injectable() +export class SoloProjectAccessGuard extends JwtAuthGuard { + constructor(private readonly installation: InstallationService) { + super(); + } + + override async canActivate(context: ExecutionContext): Promise { + if (!(await this.installation.isSolo())) return true; + await super.canActivate(context); + const request = context.switchToHttp().getRequest<{ + user: JwtUser; + query: { employeeId?: string }; + params: { employeeId?: string }; + }>(); + await this.installation.requireOwner(request.user.id); + const target = request.query.employeeId ?? request.params.employeeId; + if (target && target !== request.user.id) + throw new ForbiddenException( + 'Solo project access is restricted to the owner', + ); + return true; + } +} diff --git a/apps/api/src/app/reports/reports.controller.ts b/apps/api/src/app/reports/reports.controller.ts index 2154e6a..7677f37 100644 --- a/apps/api/src/app/reports/reports.controller.ts +++ b/apps/api/src/app/reports/reports.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { Roles } from '../auth/roles.decorator'; @@ -9,6 +10,9 @@ import { WorkingTimeReportQueryDto, } from './reports.dto'; import { ReportsService } from './reports.service'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { SoloReportDto, SoloReportQueryDto } from './reports.dto'; @ApiTags('reports') @ApiBearerAuth() @@ -18,6 +22,36 @@ import { ReportsService } from './reports.service'; export class ReportsController { constructor(private readonly reports: ReportsService) {} + @Get('solo') + @ApiOkResponse({ type: SoloReportDto }) + solo( + @CurrentUser() user: JwtUser, + @Query() query: SoloReportQueryDto, + ): Promise { + return this.reports.solo(user.id, query); + } + + @Get('solo.csv') + @ApiOkResponse({ + description: + 'UTF-8 BOM, semicolon-delimited CSV. Exact minutes; metadata rows state timezone and time definition.', + content: { 'text/csv': { schema: { type: 'string' } } }, + }) + async soloCsv( + @CurrentUser() user: JwtUser, + @Query() query: SoloReportQueryDto, + @Res() response: Response, + ): Promise { + const csv = await this.reports.soloCsv(user.id, query); + response.setHeader('Content-Type', 'text/csv; charset=utf-8'); + response.setHeader( + 'Content-Disposition', + `attachment; filename="openclockwork-${query.from}-${query.to}.csv"`, + ); + response.setHeader('Cache-Control', 'private, no-store'); + response.send(csv); + } + @Get('working-times/employees') @ApiOkResponse({ type: [WorkingTimeReportEmployeeDto] }) workingTimeEmployees(): Promise { diff --git a/apps/api/src/app/reports/reports.dto.ts b/apps/api/src/app/reports/reports.dto.ts index 9534b4a..2b3f712 100644 --- a/apps/api/src/app/reports/reports.dto.ts +++ b/apps/api/src/app/reports/reports.dto.ts @@ -110,3 +110,92 @@ export class WorkingTimeReportDto { @ApiProperty({ type: WorkingTimeReportTotalsDto }) totals!: WorkingTimeReportTotalsDto; } + +export class SoloReportQueryDto { + @ApiProperty({ format: 'date', example: '2026-09-01' }) + @Matches(new RegExp(DATE_ONLY_PATTERN)) + from!: string; + @ApiProperty({ format: 'date', example: '2026-09-30' }) + @Matches(new RegExp(DATE_ONLY_PATTERN)) + to!: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + customerId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + projectId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + serviceOrderId?: string; + @ApiPropertyOptional({ enum: ['true', 'false'] }) + @IsOptional() + @IsIn(['true', 'false']) + billable?: string; + @ApiPropertyOptional({ + enum: ['true', 'false'], + description: 'True selects only time without a project.', + }) + @IsOptional() + @IsIn(['true', 'false']) + unassigned?: string; +} + +export class SoloReportTotalsDto extends WorkingTimeReportTotalsDto { + @ApiProperty({ minimum: 0 }) + billableNetMinutes!: number; +} + +export class SoloReportRowDto extends SoloReportTotalsDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty({ format: 'date' }) + date!: string; + @ApiProperty({ format: 'date-time' }) + clockIn!: string; + @ApiProperty({ format: 'date-time' }) + clockOut!: string; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + customerId!: string | null; + @ApiProperty({ type: String, nullable: true }) + customerName!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + projectId!: string | null; + @ApiProperty({ type: String, nullable: true }) + projectCode!: string | null; + @ApiProperty({ type: String, nullable: true }) + projectName!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + serviceOrderId!: string | null; + @ApiProperty({ type: String, nullable: true }) + orderNo!: string | null; + @ApiProperty({ type: String, nullable: true }) + orderTitle!: string | null; + @ApiProperty({ type: String, nullable: true }) + activity!: string | null; + @ApiProperty() + billable!: boolean; +} + +export class SoloReportDto { + @ApiProperty({ format: 'date' }) + from!: string; + @ApiProperty({ format: 'date' }) + to!: string; + @ApiProperty() + timeZone!: string; + @ApiProperty({ enum: ['net_working_time'] }) + timeDefinition!: 'net_working_time'; + @ApiProperty({ type: [SoloReportRowDto] }) + rows!: SoloReportRowDto[]; + @ApiProperty({ type: SoloReportTotalsDto }) + totals!: SoloReportTotalsDto; + @ApiProperty({ + minimum: 0, + description: + 'Matching open timers, excluded from totals and customer statements.', + }) + openTimerCount!: number; +} diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts index 0353287..0ab9656 100644 --- a/apps/api/src/app/reports/reports.service.ts +++ b/apps/api/src/app/reports/reports.service.ts @@ -1,10 +1,16 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { summarize, parseBreakRules } from 'shared'; import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; +import { InstallationService } from '../installation/installation.service'; +import { calculateCaptureSummaries } from '../time-entries/capture-summary'; import { type WorkingTimeReportEmployeeDto, type WorkingTimeReportDto, type WorkingTimeReportQueryDto, + type SoloReportQueryDto, + type SoloReportDto, + type SoloReportRowDto, } from './reports.dto'; const DAY_MS = 86_400_000; @@ -54,7 +60,215 @@ function localDate(value: Date): string { @Injectable() export class ReportsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly installation: InstallationService, + ) {} + + async solo( + actorId: string, + query: SoloReportQueryDto, + ): Promise { + await this.installation.requireOwner(actorId); + const from = parseLocalDay(query.from); + const to = parseLocalDay(query.to); + const days = to.dayNumber - from.dayNumber + 1; + if (days < 1 || days > MAX_REPORT_DAYS) + throw new BadRequestException( + `The report range must be between 1 and ${MAX_REPORT_DAYS} days`, + ); + if ( + query.unassigned === 'true' && + (query.customerId || query.projectId || query.serviceOrderId) + ) + throw new BadRequestException( + 'Unassigned time cannot be filtered by customer, project, or service order', + ); + const end = new Date( + to.date.getFullYear(), + to.date.getMonth(), + to.date.getDate() + 1, + ); + const filter: Prisma.TimeEntryWhereInput = { + employeeId: actorId, + voidedAt: null, + status: { not: 'Rejected' }, + project: query.customerId ? { customerId: query.customerId } : undefined, + projectId: + query.unassigned === 'true' + ? null + : (query.projectId ?? + (query.unassigned === 'false' ? { not: null } : undefined)), + serviceOrderId: query.serviceOrderId, + billable: + query.billable === undefined ? undefined : query.billable === 'true', + }; + // Repeatable read makes the statement and its allocation basis one snapshot. + return this.prisma.$transaction( + async (tx) => { + await this.installation.requireOwner(actorId, tx); + const entries = await tx.timeEntry.findMany({ + where: { + ...filter, + clockIn: { lt: end }, + clockOut: { gt: from.date }, + }, + orderBy: [{ clockIn: 'asc' }, { id: 'asc' }], + include: { + project: { + select: { + id: true, + code: true, + name: true, + customerId: true, + customer: { select: { name: true } }, + }, + }, + serviceOrder: { select: { orderNo: true, title: true } }, + }, + }); + const groupIds = [ + ...new Set( + entries.flatMap((entry) => + entry.captureGroupId ? [entry.captureGroupId] : [], + ), + ), + ]; + const siblings = groupIds.length + ? await tx.timeEntry.findMany({ + where: { + employeeId: actorId, + captureGroupId: { in: groupIds }, + voidedAt: null, + status: { not: 'Rejected' }, + clockOut: { not: null }, + }, + }) + : []; + const summaries = calculateCaptureSummaries([ + ...new Map( + [...entries, ...siblings].map((entry) => [entry.id, entry]), + ).values(), + ]); + const rows: SoloReportRowDto[] = []; + for (const entry of entries) { + const summary = summaries.get(entry.id); + if (!entry.clockOut || !summary) continue; + const duration = entry.clockOut.getTime() - entry.clockIn.getTime(); + if (duration <= 0) continue; + const clippedEnd = Math.min(entry.clockOut.getTime(), end.getTime()); + let cursor = new Date( + Math.max(entry.clockIn.getTime(), from.date.getTime()), + ); + while (cursor.getTime() < clippedEnd) { + const midnight = new Date( + cursor.getFullYear(), + cursor.getMonth(), + cursor.getDate() + 1, + ); + const rowEnd = Math.min(clippedEnd, midnight.getTime()); + const ratio = (rowEnd - cursor.getTime()) / duration; + const grossMinutes = summary.grossMinutes * ratio; + const breakMinutes = summary.breakMinutes * ratio; + const netMinutes = grossMinutes - breakMinutes; + rows.push({ + id: entry.id, + date: localDate(cursor), + clockIn: cursor.toISOString(), + clockOut: new Date(rowEnd).toISOString(), + customerId: entry.project?.customerId ?? null, + customerName: entry.project?.customer?.name ?? null, + projectId: entry.projectId, + projectCode: entry.project?.code ?? null, + projectName: entry.project?.name ?? null, + serviceOrderId: entry.serviceOrderId, + orderNo: entry.serviceOrder?.orderNo ?? null, + orderTitle: entry.serviceOrder?.title ?? null, + activity: entry.activity, + billable: entry.billable, + grossMinutes, + breakMinutes, + netMinutes, + billableNetMinutes: entry.billable ? netMinutes : 0, + }); + cursor = new Date(rowEnd); + } + } + const openTimerCount = await tx.timeEntry.count({ + where: { ...filter, clockIn: { lt: end }, clockOut: null }, + }); + return { + from: query.from, + to: query.to, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timeDefinition: 'net_working_time' as const, + rows, + totals: rows.reduce( + (totals, row) => ({ + grossMinutes: totals.grossMinutes + row.grossMinutes, + breakMinutes: totals.breakMinutes + row.breakMinutes, + netMinutes: totals.netMinutes + row.netMinutes, + billableNetMinutes: + totals.billableNetMinutes + row.billableNetMinutes, + }), + { + grossMinutes: 0, + breakMinutes: 0, + netMinutes: 0, + billableNetMinutes: 0, + }, + ), + openTimerCount, + }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); + } + + async soloCsv(actorId: string, query: SoloReportQueryDto): Promise { + const report = await this.solo(actorId, query); + const rows: Array> = [ + ['working_time_zone', report.timeZone], + [ + 'time_definition', + 'gross interval minutes - allocated break minutes = net working minutes; billable uses net minutes', + ], + ['period_from', report.from, 'period_to', report.to], + [ + 'date', + 'customer', + 'project_code', + 'project', + 'service_order', + 'service_order_title', + 'activity', + 'billable', + 'gross_minutes', + 'break_minutes', + 'net_minutes', + 'billable_net_minutes', + ], + ...report.rows.map((row) => [ + row.date, + row.customerName, + row.projectCode, + row.projectName, + row.orderNo, + row.orderTitle, + row.activity, + row.billable, + row.grossMinutes, + row.breakMinutes, + row.netMinutes, + row.billableNetMinutes, + ]), + ]; + return ( + '\uFEFF' + + rows.map((row) => row.map(csvCell).join(';')).join('\r\n') + + '\r\n' + ); + } async workingTimeEmployees(): Promise { return this.prisma.employee.findMany({ @@ -95,6 +309,7 @@ export class ReportsService { clockIn: { gte: from.date, lt: toExclusive }, clockOut: { not: null }, status: { not: 'Rejected' }, + voidedAt: null, }, orderBy: [{ clockIn: 'asc' }, { employeeId: 'asc' }], include: { employee: { select: { firstName: true, lastName: true } } }, @@ -149,3 +364,11 @@ export class ReportsService { }; } } + +/** Quote every cell and neutralize spreadsheet formulas, including whitespace prefixes. */ +function csvCell(value: string | number | boolean | null): string { + let text = value === null ? '' : String(value); + if (typeof value === 'string' && /^[\s]*[=+\-@\t\r\n]/.test(text)) + text = `'${text}`; + return `"${text.replace(/"/g, '""')}"`; +} diff --git a/apps/api/src/app/time-entries/capture-summary.ts b/apps/api/src/app/time-entries/capture-summary.ts new file mode 100644 index 0000000..be5720b --- /dev/null +++ b/apps/api/src/app/time-entries/capture-summary.ts @@ -0,0 +1,81 @@ +import { + calculateBreakMinutes, + parseBreakRules, + summarize, + type TimeSummary, +} from 'shared'; + +export interface CaptureSummaryEntry { + id: string; + employeeId: string; + clockIn: Date; + clockOut: Date | null; + breakRules: unknown; + captureGroupId?: string | null; + voidedAt?: Date | null; + status?: string; +} + +/** + * Supply every member of each capture group, including members outside a report + * filter. A project split never starts a new break threshold. Solo durations keep + * millisecond precision; rounding belongs to presentation. Historical ungrouped + * team entries retain their existing per-entry calculation. + * Pass `now` only for an explicitly provisional view of an open timer. + */ +export function calculateCaptureSummaries( + entries: readonly CaptureSummaryEntry[], + now?: Date, +): Map { + const result = new Map(); + const groups = new Map(); + for (const entry of entries) { + if (entry.voidedAt || entry.status === 'Rejected') continue; + const end = entry.clockOut ?? now; + if (!end || end <= entry.clockIn) continue; + if (!entry.captureGroupId) { + result.set( + entry.id, + summarize(entry.clockIn, end, parseBreakRules(entry.breakRules)), + ); + continue; + } + const key = `${entry.employeeId}:${entry.captureGroupId}`; + const group = groups.get(key) ?? []; + group.push(entry); + groups.set(key, group); + } + for (const group of groups.values()) { + group.sort( + (a, b) => + a.clockIn.getTime() - b.clockIn.getTime() || a.id.localeCompare(b.id), + ); + const durations = group.map( + (entry) => + (entry.clockOut ?? (now as Date)).getTime() - entry.clockIn.getTime(), + ); + const totalMs = durations.reduce((sum, duration) => sum + duration, 0); + const totalBreak = calculateBreakMinutes( + totalMs / 60_000, + parseBreakRules(group[0].breakRules), + ); + let cumulativeMs = 0; + let allocatedBreak = 0; + group.forEach((entry, index) => { + cumulativeMs += durations[index]; + const throughBreak = + index === group.length - 1 + ? totalBreak + : (totalBreak * cumulativeMs) / totalMs; + const breakMinutes = throughBreak - allocatedBreak; + allocatedBreak = throughBreak; + const grossMinutes = durations[index] / 60_000; + result.set(entry.id, { + grossMinutes, + breakMinutes, + netMinutes: grossMinutes - breakMinutes, + }); + }); + } + return result; +} diff --git a/apps/api/src/app/time-entries/time-entries.controller.ts b/apps/api/src/app/time-entries/time-entries.controller.ts index c625638..3965574 100644 --- a/apps/api/src/app/time-entries/time-entries.controller.ts +++ b/apps/api/src/app/time-entries/time-entries.controller.ts @@ -9,7 +9,12 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiCreatedResponse, + ApiTags, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { JwtUser } from '../auth/jwt.strategy'; @@ -20,11 +25,16 @@ import { ClockOutDto, CreateDailyBlockDto, DailyBlockOptionDto, + ManualTimeEntryDto, + CorrectTimeEntryDto, + VoidTimeEntryDto, + SwitchProjectDto, + TimeEntryAuditDto, SplitTimeEntryDto, UpdateTimeEntryDto, - type BookProjectRangeResult, - type SplitTimeEntryResult, - type TimeEntryDto, + BookProjectRangeResult, + SplitTimeEntryResult, + TimeEntryDto, } from './time-entries.dto'; @ApiTags('time-entries') @@ -33,6 +43,7 @@ export class TimeEntriesController { constructor(private readonly entries: TimeEntriesService) {} @Get() + @ApiOkResponse({ type: TimeEntryDto, isArray: true }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) list( @@ -50,6 +61,7 @@ export class TimeEntriesController { } @Post('clock-in') + @ApiCreatedResponse({ type: TimeEntryDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) clockIn( @@ -60,6 +72,7 @@ export class TimeEntriesController { } @Post('clock-out') + @ApiCreatedResponse({ type: TimeEntryDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) clockOut( @@ -73,11 +86,15 @@ export class TimeEntriesController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @ApiOkResponse({ type: DailyBlockOptionDto }) - dailyBlockOption(@CurrentUser() user: JwtUser): Promise { - return this.entries.dailyBlockOption(user.id); + dailyBlockOption( + @CurrentUser() user: JwtUser, + @Query('date') date?: string, + ): Promise { + return this.entries.dailyBlockOption(user.id, date); } @Post('daily-block') + @ApiCreatedResponse({ type: TimeEntryDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) dailyBlock( @@ -89,6 +106,7 @@ export class TimeEntriesController { // Static route — keep declared before the ':id' routes. @Post('book-project') + @ApiCreatedResponse({ type: BookProjectRangeResult }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) bookProject( @@ -98,7 +116,66 @@ export class TimeEntriesController { return this.entries.bookProjectRange(dto, user); } + @Post('manual') + @ApiCreatedResponse({ type: TimeEntryDto }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + manual( + @Body() dto: ManualTimeEntryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.entries.createManual(dto, user); + } + + @Patch(':id/correct') + @ApiOkResponse({ type: TimeEntryDto }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + correct( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: CorrectTimeEntryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.entries.correct(id, dto, user); + } + + @Post(':id/void') + @ApiCreatedResponse({ type: TimeEntryDto }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + voidEntry( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: VoidTimeEntryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.entries.voidEntry(id, dto, user); + } + + @Post(':id/switch-project') + @ApiCreatedResponse({ type: SplitTimeEntryResult }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + switchProject( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: SwitchProjectDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.entries.switchProject(id, dto, user); + } + + @Get(':id/audit') + @ApiBearerAuth() + @ApiOkResponse({ type: TimeEntryAuditDto, isArray: true }) + @UseGuards(JwtAuthGuard) + audit( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.entries.audit(id, user); + } + @Patch(':id') + @ApiOkResponse({ type: TimeEntryDto }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) update( @@ -110,6 +187,7 @@ export class TimeEntriesController { } @Post(':id/split') + @ApiCreatedResponse({ type: SplitTimeEntryResult }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) split( diff --git a/apps/api/src/app/time-entries/time-entries.dto.ts b/apps/api/src/app/time-entries/time-entries.dto.ts index f9524f6..60a1187 100644 --- a/apps/api/src/app/time-entries/time-entries.dto.ts +++ b/apps/api/src/app/time-entries/time-entries.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, + ArrayUnique, + IsArray, IsDateString, + IsBoolean, + IsInt, IsNumber, IsOptional, IsString, @@ -9,7 +14,9 @@ import { Max, MaxLength, Min, + ValidateNested, } from 'class-validator'; +import { Type } from 'class-transformer'; import type { TimeEntry } from '@prisma/client'; import { summarize, parseBreakRules, type TimeSummary } from 'shared'; @@ -39,6 +46,17 @@ export class DailyBlockOptionDto { } export class CreateDailyBlockDto { + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + billable?: boolean; + @ApiProperty({ example: '2026-08-13', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }) @Matches(DATE_ONLY, { message: 'date must be YYYY-MM-DD' }) date!: string; @@ -65,6 +83,17 @@ export class CreateDailyBlockDto { } export class ClockInDto { + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + billable?: boolean; + @ApiPropertyOptional({ format: 'uuid', deprecated: true, @@ -128,6 +157,20 @@ export class ClockInDto { } export class ClockOutDto { + @ApiPropertyOptional({ + format: 'uuid', + description: 'Required in Solo mode to identify the timer being stopped.', + }) + @IsOptional() + @IsUUID() + id?: string; + + @ApiPropertyOptional({ minimum: 0, description: 'Required in Solo mode.' }) + @IsOptional() + @IsInt() + @Min(0) + revision?: number; + @ApiPropertyOptional({ format: 'uuid', deprecated: true, @@ -174,6 +217,26 @@ export class ClockOutDto { * mandatory service-order rule applies); activity is editable on its own. */ export class UpdateTimeEntryDto { + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string | null; + + @ApiPropertyOptional({ + minimum: 0, + description: 'Required for Solo entries; prevents lost updates.', + }) + @IsOptional() + @IsInt() + @Min(0) + revision?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + billable?: boolean; + @ApiPropertyOptional({ type: String, format: 'uuid', nullable: true }) @IsOptional() @IsUUID() @@ -192,6 +255,20 @@ export class UpdateTimeEntryDto { } export class SplitTimeEntryDto { + @ApiPropertyOptional({ + minimum: 0, + description: 'Required for Solo entries.', + }) + @IsOptional() + @IsInt() + @Min(0) + revision?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + billable?: boolean; + /** Split point, strictly between clockIn and clockOut. */ @ApiProperty({ format: 'date-time' }) @IsDateString() @@ -225,7 +302,37 @@ export class SplitTimeEntryDto { * Retroactive project booking onto an already-clocked time range. The range * must be fully covered by the employee's closed, non-rejected entries. */ +export class EntryRevisionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + id!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + revision!: number; +} + export class BookProjectRangeDto { + @ApiPropertyOptional({ + type: EntryRevisionDto, + isArray: true, + description: + 'Required for Solo. Exact IDs/revisions of all entries intersecting the range.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(1000) + @ArrayUnique((entry: EntryRevisionDto) => entry.id) + @ValidateNested({ each: true }) + @Type(() => EntryRevisionDto) + revisions?: EntryRevisionDto[]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + billable?: boolean; + @ApiProperty({ format: 'uuid' }) @IsUUID() employeeId!: string; @@ -266,6 +373,24 @@ export class TimeSummaryDto implements TimeSummary { } export class TimeEntryDto { + @ApiProperty({ type: String, nullable: true }) + note!: string | null; + + @ApiProperty({ minimum: 0 }) + revision!: number; + + @ApiProperty() + billable!: boolean; + + @ApiProperty({ type: String, format: 'date-time', nullable: true }) + voidedAt!: string | null; + + @ApiProperty({ type: String, format: 'uuid', nullable: true }) + captureGroupId!: string | null; + + @ApiProperty({ type: String, enum: ['Solo', 'Team'], nullable: true }) + approvalMode!: string | null; + @ApiProperty({ format: 'uuid' }) id!: string; @@ -394,14 +519,17 @@ export class TimeEntryDto { summary!: TimeSummary | null; } -export interface SplitTimeEntryResult { - first: TimeEntryDto; - second: TimeEntryDto; +export class SplitTimeEntryResult { + @ApiProperty({ type: TimeEntryDto }) + first!: TimeEntryDto; + @ApiProperty({ type: TimeEntryDto }) + second!: TimeEntryDto; } -export interface BookProjectRangeResult { +export class BookProjectRangeResult { /** All touched and created segments, ordered by clockIn. */ - entries: TimeEntryDto[]; + @ApiProperty({ type: TimeEntryDto, isArray: true }) + entries!: TimeEntryDto[]; } type TimeEntryWithRelations = TimeEntry & { @@ -411,6 +539,12 @@ type TimeEntryWithRelations = TimeEntry & { export function toTimeEntryDto(e: TimeEntryWithRelations): TimeEntryDto { return { + note: e.note ?? null, + revision: e.revision ?? 0, + billable: e.billable ?? false, + voidedAt: e.voidedAt?.toISOString() ?? null, + captureGroupId: e.captureGroupId ?? null, + approvalMode: e.approvalMode ?? null, id: e.id, employeeId: e.employeeId, clockIn: e.clockIn.toISOString(), @@ -456,8 +590,86 @@ export function toTimeEntryDto(e: TimeEntryWithRelations): TimeEntryDto { serviceOrderNo: e.serviceOrder?.orderNo ?? null, serviceOrderTitle: e.serviceOrder?.title ?? null, activity: e.activity ?? null, - summary: e.clockOut - ? summarize(e.clockIn, e.clockOut, parseBreakRules(e.breakRules)) - : null, + summary: + e.clockOut && !e.voidedAt && e.status !== 'Rejected' + ? summarize(e.clockIn, e.clockOut, parseBreakRules(e.breakRules)) + : null, }; } + +export class ManualTimeEntryDto extends ClockInDto { + @ApiProperty({ + format: 'date-time', + description: 'Absolute instant with UTC Z or explicit offset.', + }) + @IsDateString({ strict: true }) + @Matches(/T.*(?:Z|[+-]\d{2}:\d{2})$/) + clockIn!: string; + + @ApiProperty({ + format: 'date-time', + description: 'Absolute instant with UTC Z or explicit offset.', + }) + @IsDateString({ strict: true }) + @Matches(/T.*(?:Z|[+-]\d{2}:\d{2})$/) + clockOut!: string; +} + +export class CorrectTimeEntryDto extends ManualTimeEntryDto { + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + revision!: number; + + @ApiProperty({ maxLength: 500 }) + @IsString() + @Matches(/\S/) + @MaxLength(500) + reason!: string; +} + +export class VoidTimeEntryDto { + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + revision!: number; + + @ApiProperty({ maxLength: 500 }) + @IsString() + @Matches(/\S/) + @MaxLength(500) + reason!: string; +} + +export class SwitchProjectDto extends ClockInDto { + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + revision!: number; +} + +export class TimeEntryAuditDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ format: 'uuid' }) + timeEntryId!: string; + + @ApiProperty({ type: String, format: 'uuid', nullable: true }) + actorId!: string | null; + + @ApiProperty() + action!: string; + + @ApiProperty({ type: Object, nullable: true }) + before!: unknown; + + @ApiProperty({ type: Object, nullable: true }) + after!: unknown; + + @ApiProperty({ type: String, nullable: true }) + reason!: string | null; + + @ApiProperty({ format: 'date-time' }) + occurredAt!: string; +} diff --git a/apps/api/src/app/time-entries/time-entries.service.ts b/apps/api/src/app/time-entries/time-entries.service.ts index d498a17..27a201f 100644 --- a/apps/api/src/app/time-entries/time-entries.service.ts +++ b/apps/api/src/app/time-entries/time-entries.service.ts @@ -13,12 +13,15 @@ import { calculateGrossMinutesForNet, countWorkingDays, parseBreakRules, + holidayProviderForCalendar, requiresSpecialApproval, } from 'shared'; import { PrismaService } from '../prisma/prisma.service'; import { EventsGateway } from '../events/events.gateway'; import { WorkSchedulesService } from '../work-schedules/work-schedules.service'; import { ProjectsService } from '../projects/projects.service'; +import { InstallationService } from '../installation/installation.service'; +import { calculateCaptureSummaries } from './capture-summary'; import type { JwtUser } from '../auth/jwt.strategy'; import { toTimeEntryDto, @@ -32,6 +35,11 @@ import { type SplitTimeEntryResult, type TimeEntryDto, type UpdateTimeEntryDto, + type ManualTimeEntryDto, + type CorrectTimeEntryDto, + type VoidTimeEntryDto, + type SwitchProjectDto, + type TimeEntryAuditDto, } from './time-entries.dto'; const PROJECT_SELECT = { select: { code: true, name: true } } as const; @@ -48,6 +56,7 @@ interface BookingTarget { projectId: string | null; serviceOrderId: string | null; activity: string | null; + billable: boolean; } interface ParsedLocalBookingTime { @@ -82,6 +91,33 @@ function parseLocalBookingTime( ), ); } + // A local wall-clock time can occur twice when the UTC offset moves back. + // Reject this convenience form rather than silently choosing one occurrence. + const offsets = new Set( + [-2, -1, 1, 2].map((days) => + new Date(clockIn.getTime() + days * 86_400_000).getTimezoneOffset(), + ), + ); + for (const offset of offsets) { + if (offset === clockIn.getTimezoneOffset()) continue; + const candidate = new Date( + clockIn.getTime() + (offset - clockIn.getTimezoneOffset()) * 60_000, + ); + if ( + candidate.getFullYear() === year && + candidate.getMonth() === month - 1 && + candidate.getDate() === day && + candidate.getHours() === hour && + candidate.getMinutes() === minute + ) { + throw new BadRequestException( + dailyBlockError( + 'DAILY_BLOCK_AMBIGUOUS_DATE_TIME', + 'This local time occurs twice; choose an unambiguous daily-block start', + ), + ); + } + } return { bookingDate: new Date(Date.UTC(year, month - 1, day)), clockIn, @@ -110,6 +146,7 @@ export class TimeEntriesService { private readonly events: EventsGateway, private readonly schedules: WorkSchedulesService, private readonly projects: ProjectsService, + private readonly installation: InstallationService, ) {} async list( @@ -118,9 +155,20 @@ export class TimeEntriesService { from?: Date, to?: Date, ): Promise { - this.assertSelfOrAdmin(employeeId, user); + await this.assertAccess(employeeId, user); + const policy = await this.installation.policyFor(user.id); + if ( + (from && Number.isNaN(from.getTime())) || + (to && Number.isNaN(to.getTime())) || + (from && to && from > to) + ) { + throw new BadRequestException('Invalid time range'); + } const where: Prisma.TimeEntryWhereInput = { employeeId }; - if (from || to) { + if (policy.isSolo && (from || to)) { + if (to) where.clockIn = { lte: to }; + if (from) where.OR = [{ clockOut: null }, { clockOut: { gt: from } }]; + } else if (from || to) { where.clockIn = {}; if (from) (where.clockIn as Prisma.DateTimeFilter).gte = from; if (to) (where.clockIn as Prisma.DateTimeFilter).lte = to; @@ -128,13 +176,19 @@ export class TimeEntriesService { const rows = await this.prisma.timeEntry.findMany({ where, orderBy: { clockIn: 'desc' }, - take: 100, + take: policy.isSolo ? undefined : 100, include: ENTRY_INCLUDE, }); - return rows.map(toTimeEntryDto); + return this.withCaptureSummaries(rows); } - async dailyBlockOption(employeeId: string): Promise { + async dailyBlockOption( + employeeId: string, + date?: string, + ): Promise { + if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) + throw new BadRequestException('date must be YYYY-MM-DD'); + const at = date ? parseLocalBookingTime(date, '12:00').clockIn : new Date(); const employee = await this.prisma.employee.findUnique({ where: { id: employeeId }, select: { allowDailyBlockBooking: true, weeklyHours: true }, @@ -142,20 +196,26 @@ export class TimeEntriesService { if (!employee) throw new NotFoundException(`Employee ${employeeId} not found`); const schedule = await this.schedules.resolveForEmployee(employeeId); - const workdayCount = countWorkingDays(schedule.workingDays); + const policy = await this.installation.policyFor(employeeId, at); + const workdays = policy.isSolo ? policy.workingDays : schedule.workingDays; + const rules = policy.isSolo ? policy.breakRules : schedule.breakRules; + const workdayCount = countWorkingDays(workdays); const dailyNetMinutes = calculateDailyTargetMinutes( - Number(employee.weeklyHours), - schedule.workingDays, - ); - const grossMinutes = calculateGrossMinutesForNet( - dailyNetMinutes, - schedule.breakRules, + policy.isSolo + ? policy.targetEnabled + ? (policy.weeklyTargetMinutes ?? 0) / 60 + : 0 + : Number(employee.weeklyHours), + workdays, ); + const grossMinutes = calculateGrossMinutesForNet(dailyNetMinutes, rules); return { - enabled: employee.allowDailyBlockBooking, + enabled: policy.isSolo + ? policy.dailyBlockEnabled && policy.targetEnabled + : employee.allowDailyBlockBooking, dailyNetMinutes, grossMinutes, - breakMinutes: calculateBreakMinutes(grossMinutes, schedule.breakRules), + breakMinutes: calculateBreakMinutes(grossMinutes, rules), workdayCount, }; } @@ -164,13 +224,20 @@ export class TimeEntriesService { dto: CreateDailyBlockDto, user: JwtUser, ): Promise { + const parsed = parseLocalBookingTime(dto.date, dto.start); + const policy = await this.installation.policyFor(user.id, parsed.clockIn); + if (policy.isSolo) await this.installation.requireOwner(user.id); const employee = await this.prisma.employee.findUnique({ where: { id: user.id }, }); if (!employee || !employee.isActive) { throw new NotFoundException(`Employee ${user.id} not found`); } - if (!employee.allowDailyBlockBooking) { + if ( + policy.isSolo + ? !policy.dailyBlockEnabled || !policy.targetEnabled + : !employee.allowDailyBlockBooking + ) { throw new ForbiddenException( dailyBlockError( 'DAILY_BLOCK_DISABLED', @@ -179,7 +246,6 @@ export class TimeEntriesService { ); } - const parsed = parseLocalBookingTime(dto.date, dto.start); if (parsed.bookingDate.getTime() > localTodayAsUtcDate().getTime()) { throw new BadRequestException( dailyBlockError( @@ -198,9 +264,13 @@ export class TimeEntriesService { } const schedule = await this.schedules.resolveForEmployee(employee.id); + const workdays = policy.isSolo ? policy.workingDays : schedule.workingDays; + const rules = policy.isSolo ? policy.breakRules : schedule.breakRules; const dailyNetMinutes = calculateDailyTargetMinutes( - Number(employee.weeklyHours), - schedule.workingDays, + policy.isSolo + ? (policy.weeklyTargetMinutes ?? 0) / 60 + : Number(employee.weeklyHours), + workdays, ); if (dailyNetMinutes <= 0) { throw new BadRequestException( @@ -210,7 +280,7 @@ export class TimeEntriesService { ), ); } - if ((schedule.workingDays & weekdayBit(parsed.bookingDate)) === 0) { + if ((workdays & weekdayBit(parsed.bookingDate)) === 0) { throw new BadRequestException( dailyBlockError( 'DAILY_BLOCK_NON_WORKING_DAY', @@ -218,7 +288,14 @@ export class TimeEntriesService { ), ); } - if (schedule.holidayProvider.isHoliday(parsed.bookingDate)) { + if ( + policy.isSolo + ? holidayProviderForCalendar( + policy.holidayCalendar, + policy.holidayDates, + ).isHoliday(parsed.bookingDate) + : schedule.holidayProvider.isHoliday(parsed.bookingDate) + ) { throw new BadRequestException( dailyBlockError( 'DAILY_BLOCK_PUBLIC_HOLIDAY', @@ -227,12 +304,12 @@ export class TimeEntriesService { ); } - const grossMinutes = calculateGrossMinutesForNet( - dailyNetMinutes, - schedule.breakRules, - ); + const grossMinutes = calculateGrossMinutesForNet(dailyNetMinutes, rules); const clockOut = new Date(parsed.clockIn.getTime() + grossMinutes * 60_000); - if (requiresSpecialApproval(parsed.clockIn, clockOut, schedule.frame)) { + if ( + !policy.isSolo && + requiresSpecialApproval(parsed.clockIn, clockOut, schedule.frame) + ) { throw new BadRequestException( dailyBlockError( 'DAILY_BLOCK_OUTSIDE_FRAME', @@ -246,6 +323,7 @@ export class TimeEntriesService { dto.projectId ?? null, dto.serviceOrderId ?? null, dto.activity ?? null, + dto.billable, ); const [existingEntry, blockingAbsence, blockingRequest] = await Promise.all( @@ -253,6 +331,7 @@ export class TimeEntriesService { this.prisma.timeEntry.findFirst({ where: { employeeId: employee.id, + voidedAt: null, status: { not: 'Rejected' }, clockIn: { lt: parsed.dayEnd }, OR: [{ clockOut: null }, { clockOut: { gt: parsed.dayStart } }], @@ -322,13 +401,25 @@ export class TimeEntriesService { created = await this.prisma.$transaction( async (tx) => { await this.lockEmployee(tx, employee.id); + await this.assertTransactionAccess(tx, employee.id, policy.isSolo); + const currentPolicy = await this.installation.policyFor( + employee.id, + parsed.clockIn, + ); + if ( + policy.isSolo && + JSON.stringify(currentPolicy) !== JSON.stringify(policy) + ) + throw new ConflictException( + 'Personal rules changed; reload before booking', + ); const activeEmployee = await tx.employee.findUnique({ where: { id: employee.id }, select: { isActive: true, allowDailyBlockBooking: true }, }); if ( !activeEmployee?.isActive || - !activeEmployee.allowDailyBlockBooking + (!policy.isSolo && !activeEmployee.allowDailyBlockBooking) ) { throw new ForbiddenException( dailyBlockError( @@ -340,6 +431,7 @@ export class TimeEntriesService { const conflict = await tx.timeEntry.findFirst({ where: { employeeId: employee.id, + voidedAt: null, status: { not: 'Rejected' }, clockIn: { lt: parsed.dayEnd }, OR: [{ clockOut: null }, { clockOut: { gt: parsed.dayStart } }], @@ -354,20 +446,53 @@ export class TimeEntriesService { ), ); } - return tx.timeEntry.create({ + await this.assertTargetStillBookable(tx, target); + if ( + policy.isSolo && + (await tx.personalDay.findFirst({ + where: { + employeeId: employee.id, + cancelledAt: null, + from: { lte: parsed.bookingDate }, + to: { gte: parsed.bookingDate }, + }, + select: { id: true }, + })) + ) { + throw new ConflictException( + dailyBlockError( + 'DAILY_BLOCK_ABSENCE_CONFLICT', + 'Selected day is covered by a personal calendar entry', + ), + ); + } + const row = await tx.timeEntry.create({ data: { employeeId: employee.id, clockIn: parsed.clockIn, clockOut, bookingDate: parsed.bookingDate, source: 'DailyBlock', - breakRules: schedule.breakRules, + note: dto.note ?? null, + breakRules: rules, + captureGroupId: policy.isSolo ? randomUUID() : null, + approvalMode: policy.isSolo ? 'Solo' : 'Team', status: 'Approved', requiresApproval: false, ...target, }, include: ENTRY_INCLUDE, }); + if (policy.isSolo) + await this.writeAudit( + tx, + row, + user.id, + 'DailyBlockCreated', + null, + null, + ); + return row; }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, ); @@ -393,11 +518,14 @@ export class TimeEntriesService { async clockIn(dto: ClockInDto, employeeId: string): Promise { this.assertLocationTuple(dto.latitude, dto.longitude, dto.accuracyMeters); await this.assertActiveEmployee(employeeId); + let policy = await this.installation.policyFor(employeeId); + if (policy.isSolo) await this.installation.requireOwner(employeeId); const target = await this.resolveBookingTarget( employeeId, dto.projectId ?? null, dto.serviceOrderId ?? null, dto.activity ?? null, + dto.billable, ); const schedule = await this.schedules.resolveForEmployee(employeeId); let created: TimeEntry & { @@ -408,6 +536,8 @@ export class TimeEntriesService { created = await this.prisma.$transaction( async (tx) => { await this.lockEmployee(tx, employeeId); + await this.assertTransactionAccess(tx, employeeId, policy.isSolo); + await this.assertTargetStillBookable(tx, target); const activeEmployee = await tx.employee.findUnique({ where: { id: employeeId }, select: { isActive: true }, @@ -416,16 +546,19 @@ export class TimeEntriesService { throw new ForbiddenException('Employee account is not active'); } const bookingNow = new Date(); + policy = await this.installation.policyFor(employeeId, bookingNow); const today = localTodayAsUtcDate(bookingNow); const [dailyBlock, open] = await Promise.all([ - tx.timeEntry.findUnique({ + tx.timeEntry.findFirst({ where: { - employeeId_bookingDate: { employeeId, bookingDate: today }, + employeeId, + bookingDate: today, + voidedAt: null, }, select: { id: true }, }), tx.timeEntry.findFirst({ - where: { employeeId, clockOut: null }, + where: { employeeId, clockOut: null, voidedAt: null }, select: { id: true }, }), ]); @@ -439,25 +572,42 @@ export class TimeEntriesService { 'There is already an open time entry — clock out first', ); } - return tx.timeEntry.create({ + if (policy.isSolo) + await this.assertNoOverlap(tx, employeeId, bookingNow, null); + const row = await tx.timeEntry.create({ data: { employeeId, clockIn: bookingNow, source: 'Pwa', - breakRules: schedule.breakRules, + note: dto.note ?? null, + breakRules: policy.isSolo + ? policy.breakRules + : schedule.breakRules, + captureGroupId: policy.isSolo ? randomUUID() : null, + approvalMode: policy.isSolo ? 'Solo' : 'Team', status: 'Open', - requiresApproval: requiresSpecialApproval( - bookingNow, - null, - schedule.frame, - ), - latitude: dto.latitude ?? null, - longitude: dto.longitude ?? null, - accuracyMeters: dto.accuracyMeters ?? null, + requiresApproval: + !policy.isSolo && + requiresSpecialApproval(bookingNow, null, schedule.frame), + latitude: + policy.isSolo && !policy.gpsEnabled + ? null + : (dto.latitude ?? null), + longitude: + policy.isSolo && !policy.gpsEnabled + ? null + : (dto.longitude ?? null), + accuracyMeters: + policy.isSolo && !policy.gpsEnabled + ? null + : (dto.accuracyMeters ?? null), ...target, }, include: ENTRY_INCLUDE, }); + if (policy.isSolo) + await this.writeAudit(tx, row, employeeId, 'ClockedIn', null, null); + return row; }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, ); @@ -484,9 +634,12 @@ export class TimeEntriesService { this.assertLocationTuple(dto.latitude, dto.longitude, dto.accuracyMeters); await this.assertActiveEmployee(employeeId); const schedule = await this.schedules.resolveForEmployee(employeeId); + const policy = await this.installation.policyFor(employeeId); + if (policy.isSolo) await this.installation.requireOwner(employeeId); const updated = await this.prisma.$transaction( async (tx) => { await this.lockEmployee(tx, employeeId); + await this.assertTransactionAccess(tx, employeeId, policy.isSolo); const activeEmployee = await tx.employee.findUnique({ where: { id: employeeId }, select: { isActive: true }, @@ -496,27 +649,51 @@ export class TimeEntriesService { } const bookingNow = new Date(); const open = await tx.timeEntry.findFirst({ - where: { employeeId, clockOut: null }, + where: { employeeId, clockOut: null, voidedAt: null }, orderBy: { clockIn: 'desc' }, }); if (!open) throw new NotFoundException('No open time entry to close'); + if (open.approvalMode === 'Solo' && !dto.id) + throw new BadRequestException('id is required to stop a Solo timer'); + if (dto.id && dto.id !== open.id) + throw new ConflictException( + 'The running timer changed; reload before stopping', + ); + this.assertMutable(open, dto.revision); if (bookingNow.getTime() <= open.clockIn.getTime()) { throw new BadRequestException('Clock-out must be after clock-in'); } - const requires = requiresSpecialApproval( - open.clockIn, - bookingNow, - schedule.frame, - ); + const requires = + open.approvalMode !== 'Solo' && + requiresSpecialApproval(open.clockIn, bookingNow, schedule.frame); + if (policy.isSolo) + await this.assertNoOverlap( + tx, + employeeId, + open.clockIn, + bookingNow, + open.id, + ); + const before = await this.auditSnapshot(tx, open); const result = await tx.timeEntry.updateMany({ where: { id: open.id, clockOut: null }, data: { clockOut: bookingNow, status: requires ? 'Pending' : 'Approved', requiresApproval: requires, - clockOutLatitude: dto.latitude ?? null, - clockOutLongitude: dto.longitude ?? null, - clockOutAccuracyMeters: dto.accuracyMeters ?? null, + revision: { increment: 1 }, + clockOutLatitude: + policy.isSolo && !policy.gpsEnabled + ? null + : (dto.latitude ?? null), + clockOutLongitude: + policy.isSolo && !policy.gpsEnabled + ? null + : (dto.longitude ?? null), + clockOutAccuracyMeters: + policy.isSolo && !policy.gpsEnabled + ? null + : (dto.accuracyMeters ?? null), }, }); if (result.count !== 1) { @@ -527,6 +704,15 @@ export class TimeEntriesService { include: ENTRY_INCLUDE, }); if (!row) throw new NotFoundException('No open time entry to close'); + if (open.approvalMode === 'Solo') + await this.writeAudit( + tx, + row, + employeeId, + 'ClockedOut', + before, + null, + ); return row; }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, @@ -536,7 +722,7 @@ export class TimeEntriesService { employeeId: updated.employeeId, clockOut: updated.clockOut?.toISOString() ?? null, }); - return toTimeEntryDto(updated); + return (await this.withCaptureSummaries([updated]))[0]; } /** @@ -554,14 +740,17 @@ export class TimeEntriesService { if ( dto.projectId === undefined && dto.serviceOrderId === undefined && - dto.activity === undefined + dto.activity === undefined && + dto.billable === undefined && + dto.note === undefined ) { throw new BadRequestException( 'At least one of projectId, serviceOrderId, activity must be provided', ); } const entry = await this.findOrThrow(id); - this.assertOwnerOrAdmin(entry, user); + await this.assertAccess(entry.employeeId, user); + this.assertMutable(entry, dto.revision); const data: Prisma.TimeEntryUncheckedUpdateInput = {}; if (dto.projectId !== undefined) { @@ -570,9 +759,11 @@ export class TimeEntriesService { dto.projectId, dto.serviceOrderId ?? null, undefined, + dto.billable, ); data.projectId = target.projectId; data.serviceOrderId = target.serviceOrderId; + data.billable = target.billable; } else if (dto.serviceOrderId !== undefined) { if (dto.serviceOrderId !== null && entry.projectId === null) { throw new BadRequestException('serviceOrderId requires a projectId'); @@ -588,18 +779,48 @@ export class TimeEntriesService { } } if (dto.activity !== undefined) data.activity = dto.activity; - - const updated = await this.prisma.timeEntry.update({ - where: { id }, - data, - include: ENTRY_INCLUDE, + if (dto.note !== undefined) data.note = dto.note; + if (dto.billable !== undefined) data.billable = dto.billable; + const updated = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, entry.employeeId); + const isSolo = await this.assertTransactionAccess( + tx, + entry.employeeId, + undefined, + user.id, + ); + if (isSolo && dto.revision === undefined) + throw new BadRequestException('revision is required in Solo mode'); + const current = await tx.timeEntry.findUniqueOrThrow({ where: { id } }); + this.assertMutable(current, dto.revision, entry.revision); + if (dto.projectId !== undefined || dto.serviceOrderId !== undefined) { + await this.assertTargetStillBookable(tx, { + projectId: + dto.projectId === undefined ? current.projectId : dto.projectId, + serviceOrderId: + dto.projectId !== undefined + ? (data.serviceOrderId as string | null) + : (dto.serviceOrderId ?? null), + activity: current.activity, + billable: current.billable, + }); + } + const before = await this.auditSnapshot(tx, current); + const row = await tx.timeEntry.update({ + where: { id }, + data: { ...data, revision: { increment: 1 } }, + include: ENTRY_INCLUDE, + }); + if (isSolo || current.approvalMode === 'Solo') + await this.writeAudit(tx, row, user.id, 'BookingUpdated', before, null); + return row; }); this.events.broadcast('time-entry:updated', { id: updated.id, employeeId: updated.employeeId, clockOut: updated.clockOut?.toISOString() ?? null, }); - return toTimeEntryDto(updated); + return (await this.withCaptureSummaries([updated]))[0]; } /** @@ -614,7 +835,8 @@ export class TimeEntriesService { user: JwtUser, ): Promise { const entry = await this.findOrThrow(id); - this.assertOwnerOrAdmin(entry, user); + await this.assertAccess(entry.employeeId, user); + this.assertMutable(entry, dto.revision); if (!entry.clockOut) { throw new BadRequestException( 'Open entries cannot be split — clock out first', @@ -638,25 +860,23 @@ export class TimeEntriesService { projectId: entry.projectId, serviceOrderId: entry.serviceOrderId, activity: entry.activity, + billable: dto.billable ?? entry.billable, } : await this.resolveBookingTarget( entry.employeeId, dto.projectId, dto.serviceOrderId ?? null, dto.activity ?? null, + dto.billable, ); const schedule = await this.schedules.resolveForEmployee(entry.employeeId); - const firstRequires = requiresSpecialApproval( - entry.clockIn, - at, - schedule.frame, - ); - const secondRequires = requiresSpecialApproval( - at, - entry.clockOut, - schedule.frame, - ); + const firstRequires = + entry.approvalMode !== 'Solo' && + requiresSpecialApproval(entry.clockIn, at, schedule.frame); + const secondRequires = + entry.approvalMode !== 'Solo' && + requiresSpecialApproval(at, entry.clockOut, schedule.frame); // A rejected entry must not be laundered into approved segments. const statusFor = (requires: boolean) => entry.status === 'Rejected' @@ -668,10 +888,28 @@ export class TimeEntriesService { const [first, secondEntry] = await this.prisma.$transaction( async (tx) => { await this.lockEmployee(tx, entry.employeeId); + const isSolo = await this.assertTransactionAccess( + tx, + entry.employeeId, + undefined, + user.id, + ); + if (isSolo && dto.revision === undefined) + throw new BadRequestException('revision is required in Solo mode'); + const current = await tx.timeEntry.findUniqueOrThrow({ where: { id } }); + this.assertMutable(current, dto.revision, entry.revision); + if (dto.projectId !== undefined) + await this.assertTargetStillBookable(tx, second); + const before = await this.auditSnapshot(tx, current); + const captureGroupId = + entry.captureGroupId ?? + (isSolo || entry.approvalMode === 'Solo' ? randomUUID() : null); const firstSegment = await tx.timeEntry.update({ where: { id }, data: { clockOut: at, + captureGroupId, + revision: { increment: 1 }, requiresApproval: firstRequires, status: statusFor(firstRequires), clockOutLatitude: null, @@ -693,6 +931,8 @@ export class TimeEntriesService { clockIn: at, clockOut: entry.clockOut, source: entry.source, + captureGroupId, + approvalMode: entry.approvalMode, breakRules: parseBreakRules(entry.breakRules), status: statusFor(secondRequires), requiresApproval: secondRequires, @@ -716,6 +956,24 @@ export class TimeEntriesService { where: { timeEntryId: entry.id, action: 'clock-out' }, data: { timeEntryId: secondSegment.id }, }); + if (isSolo || entry.approvalMode === 'Solo') { + await this.writeAudit( + tx, + firstSegment, + user.id, + 'Split', + before, + null, + ); + await this.writeAudit( + tx, + secondSegment, + user.id, + 'SplitCreated', + null, + null, + ); + } return [firstSegment, secondSegment] as const; }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, @@ -731,10 +989,8 @@ export class TimeEntriesService { employeeId: secondEntry.employeeId, clockIn: secondEntry.clockIn.toISOString(), }); - return { - first: toTimeEntryDto(first), - second: toTimeEntryDto(secondEntry), - }; + const mapped = await this.withCaptureSummaries([first, secondEntry]); + return { first: mapped[0], second: mapped[1] }; } /** @@ -748,12 +1004,12 @@ export class TimeEntriesService { dto: BookProjectRangeDto, user: JwtUser, ): Promise { - this.assertSelfOrAdmin(dto.employeeId, user); + await this.assertAccess(dto.employeeId, user); const from = new Date(dto.from); const to = new Date(dto.to); if ( - Number.isNaN(from.getTime()) || - Number.isNaN(to.getTime()) || + !Number.isFinite(from.getTime()) || + !Number.isFinite(to.getTime()) || from >= to ) { throw new BadRequestException('from must be before to'); @@ -763,230 +1019,420 @@ export class TimeEntriesService { dto.projectId, dto.serviceOrderId ?? null, dto.activity ?? null, + dto.billable, ); - - // Open and rejected entries never count as coverage and are never touched. - const overlapping = await this.prisma.timeEntry.findMany({ - where: { - employeeId: dto.employeeId, - status: { not: 'Rejected' }, - clockIn: { lt: to }, - clockOut: { not: null, gt: from }, + const schedule = await this.schedules.resolveForEmployee(dto.employeeId); + const rows = await this.prisma.$transaction( + async (tx) => { + await this.lockEmployee(tx, dto.employeeId); + const isSolo = await this.assertTransactionAccess( + tx, + dto.employeeId, + undefined, + user.id, + ); + await this.assertTargetStillBookable(tx, target); + const overlapping = await tx.timeEntry.findMany({ + where: { + employeeId: dto.employeeId, + voidedAt: null, + status: { not: 'Rejected' }, + clockIn: { lt: to }, + clockOut: { not: null, gt: from }, + }, + orderBy: { clockIn: 'asc' }, + }); + this.assertRangeCovered(from, to, overlapping); + if (isSolo && !dto.revisions) + throw new BadRequestException('revisions are required in Solo mode'); + if (dto.revisions) { + const revisions = new Map( + dto.revisions.map((entry) => [entry.id, entry.revision]), + ); + if ( + revisions.size !== overlapping.length || + overlapping.some( + (entry) => revisions.get(entry.id) !== entry.revision, + ) + ) + throw new ConflictException( + 'Time entries changed; reload before booking the range', + ); + } + const changed: Array< + TimeEntry & { + project: { code: string; name: string } | null; + serviceOrder: { orderNo: string; title: string } | null; + } + > = []; + for (const entry of overlapping) { + const before = await this.auditSnapshot(tx, entry); + const end = entry.clockOut as Date; + const points = [ + entry.clockIn, + ...[from, to].filter( + (point) => point > entry.clockIn && point < end, + ), + end, + ]; + const captureGroupId = + entry.captureGroupId ?? + (isSolo || entry.approvalMode === 'Solo' ? randomUUID() : null); + const originalTarget: BookingTarget = { + projectId: entry.projectId, + serviceOrderId: entry.serviceOrderId, + activity: entry.activity, + billable: entry.billable, + }; + const segments: typeof changed = []; + for (let index = 0; index < points.length - 1; index++) { + const clockIn = points[index]; + const clockOut = points[index + 1]; + const requiresApproval = + entry.approvalMode !== 'Solo' && + requiresSpecialApproval(clockIn, clockOut, schedule.frame); + const booking = + clockIn >= from && clockOut <= to ? target : originalTarget; + const common = { + clockOut, + captureGroupId, + requiresApproval, + status: requiresApproval + ? ('Pending' as const) + : ('Approved' as const), + ...booking, + ...(clockOut.getTime() === end.getTime() + ? this.clockOutAudit(entry) + : this.clearClockOutAudit()), + }; + const row = + index === 0 + ? await tx.timeEntry.update({ + where: { id: entry.id }, + data: { ...common, revision: { increment: 1 } }, + include: ENTRY_INCLUDE, + }) + : await tx.timeEntry.create({ + data: { + ...common, + employeeId: entry.employeeId, + clockIn, + source: entry.source, + approvalMode: entry.approvalMode, + breakRules: parseBreakRules(entry.breakRules), + }, + include: ENTRY_INCLUDE, + }); + segments.push(row); + changed.push(row); + } + const last = segments[segments.length - 1]; + if (last.id !== entry.id) + await tx.terminalChallengeRedemption.updateMany({ + where: { timeEntryId: entry.id, action: 'clock-out' }, + data: { timeEntryId: last.id }, + }); + if (isSolo || entry.approvalMode === 'Solo') { + for (const segment of segments) + await this.writeAudit( + tx, + segment, + user.id, + segment.id === entry.id ? 'RangeBooked' : 'RangeSegmentCreated', + segment.id === entry.id ? before : null, + null, + ); + } + } + return changed; }, - orderBy: { clockIn: 'asc' }, + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, + ); + rows.forEach((row) => + this.events.broadcast('time-entry:updated', { + id: row.id, + employeeId: row.employeeId, + clockOut: row.clockOut?.toISOString() ?? null, + }), + ); + const entries = (await this.withCaptureSummaries(rows)).sort((a, b) => + a.clockIn.localeCompare(b.clockIn), + ); + return { entries }; + } + async createManual( + dto: ManualTimeEntryDto, + user: JwtUser, + ): Promise { + await this.installation.requireOwner(user.id); + const clockIn = new Date(dto.clockIn); + const clockOut = new Date(dto.clockOut); + this.assertClosedInterval(clockIn, clockOut); + let policy = await this.installation.policyFor(user.id, clockIn); + const target = await this.resolveBookingTarget( + user.id, + dto.projectId ?? null, + dto.serviceOrderId ?? null, + dto.activity ?? null, + dto.billable, + ); + const row = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, user.id); + await this.assertTransactionAccess(tx, user.id, true); + policy = await this.installation.policyFor(user.id, clockIn); + await this.assertTargetStillBookable(tx, target); + await this.assertNoOverlap(tx, user.id, clockIn, clockOut); + const created = await tx.timeEntry.create({ + data: { + employeeId: user.id, + clockIn, + clockOut, + note: dto.note ?? null, + source: 'Manual', + status: 'Approved', + requiresApproval: false, + approvalMode: 'Solo', + captureGroupId: randomUUID(), + breakRules: policy.breakRules, + ...target, + }, + include: ENTRY_INCLUDE, + }); + await this.writeAudit(tx, created, user.id, 'ManualCreated', null, null); + return created; }); - this.assertRangeCovered(from, to, overlapping); - - const schedule = await this.schedules.resolveForEmployee(dto.employeeId); - const requiresFor = (a: Date, b: Date) => - requiresSpecialApproval(a, b, schedule.frame); - const statusFor = (requires: boolean): 'Pending' | 'Approved' => - requires ? 'Pending' : 'Approved'; + this.events.broadcast('time-entry:created', { + id: row.id, + employeeId: user.id, + clockIn: row.clockIn.toISOString(), + }); + return (await this.withCaptureSummaries([row]))[0]; + } - const ops: Prisma.PrismaPromise[] = []; - const kinds: Array<'updated' | 'created'> = []; - const clockOutRedemptionRemaps: Array<{ from: string; to: string }> = []; - const push = ( - op: Prisma.PrismaPromise, - kind: 'updated' | 'created', - ) => { - ops.push(op); - kinds.push(kind); - }; - const newFields = { ...target }; + async correct( + id: string, + dto: CorrectTimeEntryDto, + user: JwtUser, + ): Promise { + await this.installation.requireOwner(user.id); + const entry = await this.findOrThrow(id); + if (entry.employeeId !== user.id) + throw new ForbiddenException('Only your own entries can be corrected'); + this.assertMutable(entry, dto.revision); + const clockIn = new Date(dto.clockIn); + const clockOut = new Date(dto.clockOut); + this.assertClosedInterval(clockIn, clockOut); + const target = + dto.projectId !== undefined || dto.serviceOrderId !== undefined + ? await this.resolveBookingTarget( + user.id, + dto.projectId === undefined ? entry.projectId : dto.projectId, + dto.serviceOrderId ?? null, + dto.activity === undefined ? entry.activity : dto.activity, + dto.billable, + ) + : { + projectId: entry.projectId, + serviceOrderId: entry.serviceOrderId, + activity: + dto.activity === undefined ? entry.activity : dto.activity, + billable: dto.billable ?? entry.billable, + }; + const row = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, user.id); + await this.assertTransactionAccess(tx, user.id, true); + const current = await tx.timeEntry.findUniqueOrThrow({ where: { id } }); + this.assertMutable(current, dto.revision, entry.revision); + if (dto.projectId !== undefined || dto.serviceOrderId !== undefined) + await this.assertTargetStillBookable(tx, target); + await this.assertNoOverlap(tx, user.id, clockIn, clockOut, id); + const before = await this.auditSnapshot(tx, current); + const corrected = await tx.timeEntry.update({ + where: { id }, + data: { + clockIn, + clockOut, + ...target, + ...(dto.note !== undefined ? { note: dto.note } : {}), + // A corrected block becomes an ordinary interval; the audit retains its date. + bookingDate: + current.source === 'DailyBlock' ? null : current.bookingDate, + revision: { increment: 1 }, + ...(current.approvalMode === 'Solo' + ? { status: 'Approved' as const, requiresApproval: false } + : {}), + }, + include: ENTRY_INCLUDE, + }); + await this.writeAudit( + tx, + corrected, + user.id, + 'Corrected', + before, + dto.reason.trim(), + ); + return corrected; + }); + this.events.broadcast('time-entry:updated', { + id, + employeeId: user.id, + clockOut: row.clockOut?.toISOString() ?? null, + }); + return (await this.withCaptureSummaries([row]))[0]; + } - for (const entry of overlapping) { - const s = entry.clockIn; - const e = entry.clockOut as Date; - const origFields: BookingTarget = { - projectId: entry.projectId, - serviceOrderId: entry.serviceOrderId, - activity: entry.activity, - }; - const startsBefore = s < from; - const endsAfter = to < e; + async voidEntry( + id: string, + dto: VoidTimeEntryDto, + user: JwtUser, + ): Promise { + await this.installation.requireOwner(user.id); + const row = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, user.id); + await this.assertTransactionAccess(tx, user.id, true); + const current = await tx.timeEntry.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('Time entry not found'); + if (current.employeeId !== user.id) + throw new ForbiddenException('Only your own entries can be voided'); + this.assertMutable(current, dto.revision); + const before = await this.auditSnapshot(tx, current); + const now = new Date(); + const updated = await tx.timeEntry.update({ + where: { id }, + data: { + voidedAt: now, + clockOut: + current.clockOut ?? + new Date(Math.max(now.getTime(), current.clockIn.getTime() + 1)), + bookingDate: null, + revision: { increment: 1 }, + }, + include: ENTRY_INCLUDE, + }); + await this.writeAudit( + tx, + updated, + user.id, + 'Voided', + before, + dto.reason.trim(), + ); + return updated; + }); + this.events.broadcast('time-entry:updated', { + id, + employeeId: user.id, + clockOut: row.clockOut?.toISOString() ?? null, + }); + return (await this.withCaptureSummaries([row]))[0]; + } - if (!startsBefore && !endsAfter) { - // Case A — fully inside: retarget the whole entry (keeps GPS/note). - push( - this.prisma.timeEntry.update({ - where: { id: entry.id }, - data: { - ...newFields, - requiresApproval: requiresFor(s, e), - status: statusFor(requiresFor(s, e)), - }, - include: ENTRY_INCLUDE, - }), - 'updated', - ); - } else if (startsBefore && !endsAfter) { - // Case B — sticks out left: original keeps its booking up to `from`. - const lastSegmentId = randomUUID(); - push( - this.prisma.timeEntry.update({ - where: { id: entry.id }, - data: { - clockOut: from, - ...this.clearClockOutAudit(), - requiresApproval: requiresFor(s, from), - status: statusFor(requiresFor(s, from)), - }, - include: ENTRY_INCLUDE, - }), - 'updated', - ); - push( - this.prisma.timeEntry.create({ - data: { - id: lastSegmentId, - employeeId: entry.employeeId, - clockIn: from, - clockOut: e, - source: entry.source, - breakRules: parseBreakRules(entry.breakRules), - requiresApproval: requiresFor(from, e), - status: statusFor(requiresFor(from, e)), - ...this.clockOutAudit(entry), - ...newFields, - }, - include: ENTRY_INCLUDE, - }), - 'created', - ); - clockOutRedemptionRemaps.push({ - from: entry.id, - to: lastSegmentId, - }); - } else if (!startsBefore && endsAfter) { - // Case C — sticks out right: original (first physical segment, keeps - // GPS/note) gets the new booking up to `to`; the rest keeps the old one. - const lastSegmentId = randomUUID(); - push( - this.prisma.timeEntry.update({ - where: { id: entry.id }, - data: { - clockOut: to, - ...this.clearClockOutAudit(), - ...newFields, - requiresApproval: requiresFor(s, to), - status: statusFor(requiresFor(s, to)), - }, - include: ENTRY_INCLUDE, - }), - 'updated', - ); - push( - this.prisma.timeEntry.create({ - data: { - id: lastSegmentId, - employeeId: entry.employeeId, - clockIn: to, - clockOut: e, - source: entry.source, - breakRules: parseBreakRules(entry.breakRules), - requiresApproval: requiresFor(to, e), - status: statusFor(requiresFor(to, e)), - ...this.clockOutAudit(entry), - ...origFields, - }, - include: ENTRY_INCLUDE, - }), - 'created', - ); - clockOutRedemptionRemaps.push({ - from: entry.id, - to: lastSegmentId, - }); - } else { - // Case D — sticks out both sides: old | new | old. - const lastSegmentId = randomUUID(); - push( - this.prisma.timeEntry.update({ - where: { id: entry.id }, - data: { - clockOut: from, - ...this.clearClockOutAudit(), - requiresApproval: requiresFor(s, from), - status: statusFor(requiresFor(s, from)), - }, - include: ENTRY_INCLUDE, - }), - 'updated', - ); - push( - this.prisma.timeEntry.create({ - data: { - employeeId: entry.employeeId, - clockIn: from, - clockOut: to, - source: entry.source, - breakRules: parseBreakRules(entry.breakRules), - requiresApproval: requiresFor(from, to), - status: statusFor(requiresFor(from, to)), - ...newFields, - }, - include: ENTRY_INCLUDE, - }), - 'created', + async switchProject( + id: string, + dto: SwitchProjectDto, + user: JwtUser, + ): Promise { + await this.installation.requireOwner(user.id); + const target = await this.resolveBookingTarget( + user.id, + dto.projectId ?? null, + dto.serviceOrderId ?? null, + dto.activity ?? null, + dto.billable, + ); + const rows = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, user.id); + await this.assertTransactionAccess(tx, user.id, true); + const entry = await tx.timeEntry.findUnique({ where: { id } }); + if (!entry) throw new NotFoundException('Time entry not found'); + if (entry.employeeId !== user.id) + throw new ForbiddenException('Only your own timer can switch projects'); + this.assertMutable(entry, dto.revision); + if (entry.clockOut || entry.approvalMode !== 'Solo') + throw new BadRequestException( + 'Only a running Solo timer can switch projects', ); - push( - this.prisma.timeEntry.create({ - data: { - id: lastSegmentId, - employeeId: entry.employeeId, - clockIn: to, - clockOut: e, - source: entry.source, - breakRules: parseBreakRules(entry.breakRules), - requiresApproval: requiresFor(to, e), - status: statusFor(requiresFor(to, e)), - ...this.clockOutAudit(entry), - ...origFields, - }, - include: ENTRY_INCLUDE, - }), - 'created', + await this.assertTargetStillBookable(tx, target); + const at = new Date(); + if (at <= entry.clockIn) + throw new ConflictException( + 'Timer has not advanced; retry the project switch', ); - clockOutRedemptionRemaps.push({ - from: entry.id, - to: lastSegmentId, - }); - } - } + await this.assertNoOverlap(tx, user.id, entry.clockIn, null, id); + const before = await this.auditSnapshot(tx, entry); + const captureGroupId = entry.captureGroupId ?? randomUUID(); + const first = await tx.timeEntry.update({ + where: { id }, + data: { + clockOut: at, + status: 'Approved', + requiresApproval: false, + revision: { increment: 1 }, + captureGroupId, + }, + include: ENTRY_INCLUDE, + }); + const second = await tx.timeEntry.create({ + data: { + employeeId: user.id, + clockIn: at, + note: dto.note ?? null, + source: 'Pwa', + status: 'Open', + requiresApproval: false, + approvalMode: 'Solo', + captureGroupId, + breakRules: parseBreakRules(entry.breakRules), + ...target, + }, + include: ENTRY_INCLUDE, + }); + await this.writeAudit( + tx, + first, + user.id, + 'ProjectSwitched', + before, + null, + ); + await this.writeAudit( + tx, + second, + user.id, + 'ProjectSwitchCreated', + null, + null, + ); + return [first, second]; + }); + this.events.broadcast('time-entry:updated', { + id, + employeeId: user.id, + clockOut: rows[0].clockOut?.toISOString() ?? null, + }); + this.events.broadcast('time-entry:created', { + id: rows[1].id, + employeeId: user.id, + clockIn: rows[1].clockIn.toISOString(), + }); + const mapped = await this.withCaptureSummaries(rows); + return { first: mapped[0], second: mapped[1] }; + } - const lockOp = this.prisma - .$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${dto.employeeId}, 0))`; - const redemptionOps = clockOutRedemptionRemaps.map((remap) => - this.prisma.terminalChallengeRedemption.updateMany({ - where: { timeEntryId: remap.from, action: 'clock-out' }, - data: { timeEntryId: remap.to }, - }), - ); - const transactionResults = await this.prisma.$transaction( - [lockOp, ...ops, ...redemptionOps] as Prisma.PrismaPromise[], - { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, - ); - const results = transactionResults.slice(1, ops.length + 1) as TimeEntry[]; - results.forEach((row, i) => { - if (kinds[i] === 'updated') { - this.events.broadcast('time-entry:updated', { - id: row.id, - employeeId: row.employeeId, - clockOut: row.clockOut?.toISOString() ?? null, - }); - } else { - this.events.broadcast('time-entry:created', { - id: row.id, - employeeId: row.employeeId, - clockIn: row.clockIn.toISOString(), - }); - } + async audit(id: string, user: JwtUser): Promise { + await this.installation.requireOwner(user.id); + const entry = await this.findOrThrow(id); + if (entry.employeeId !== user.id) + throw new ForbiddenException('Only your own audit history is available'); + const rows = await this.prisma.timeEntryAudit.findMany({ + where: { timeEntryId: id }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], }); - const entries = results - .map(toTimeEntryDto) - .sort((a, b) => a.clockIn.localeCompare(b.clockIn)); - return { entries }; + return rows.map((row) => ({ + ...row, + occurredAt: row.occurredAt.toISOString(), + })); } /** @@ -999,6 +1445,7 @@ export class TimeEntriesService { projectId: string | null, serviceOrderId: string | null, activity: string | null | undefined, + billable?: boolean, ): Promise { if (projectId === null) { if (serviceOrderId !== null) { @@ -1008,6 +1455,7 @@ export class TimeEntriesService { projectId: null, serviceOrderId: null, activity: activity ?? null, + billable: billable ?? false, }; } await this.projects.assertBookable(employeeId, projectId); @@ -1015,10 +1463,15 @@ export class TimeEntriesService { projectId, serviceOrderId, ); + const project = await this.prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + select: { defaultBillable: true }, + }); return { projectId, serviceOrderId: order?.id ?? null, activity: activity ?? null, + billable: billable ?? order?.defaultBillable ?? project.defaultBillable, }; } @@ -1061,8 +1514,244 @@ export class TimeEntriesService { ); } - private assertOwnerOrAdmin(entry: TimeEntry, user: JwtUser): void { - this.assertSelfOrAdmin(entry.employeeId, user); + private async assertAccess(employeeId: string, user: JwtUser): Promise { + const policy = await this.installation.policyFor(user.id); + if (policy.isSolo) { + await this.installation.requireOwner(user.id); + if (employeeId !== user.id) + throw new ForbiddenException( + 'Solo access is limited to your own time entries', + ); + } + this.assertSelfOrAdmin(employeeId, user); + } + + private assertMutable( + entry: TimeEntry, + revision?: number, + expectedRevision?: number, + ): void { + if (entry.voidedAt) + throw new ConflictException('A voided time entry cannot be changed'); + if (entry.approvalMode === 'Solo' && revision === undefined) + throw new BadRequestException( + 'revision is required for Solo time entries', + ); + if ( + (revision !== undefined && revision !== entry.revision) || + (expectedRevision !== undefined && expectedRevision !== entry.revision) + ) { + throw new ConflictException('Time entry changed; reload before editing'); + } + } + + private assertClosedInterval(clockIn: Date, clockOut: Date): void { + if ( + !Number.isFinite(clockIn.getTime()) || + !Number.isFinite(clockOut.getTime()) || + clockOut <= clockIn + ) + throw new BadRequestException('clockOut must be after clockIn'); + if (clockOut.getTime() > Date.now()) + throw new BadRequestException( + 'Working time cannot be booked in the future', + ); + } + + private async assertTransactionAccess( + tx: Prisma.TransactionClient, + employeeId: string, + expectedSolo?: boolean, + actorId = employeeId, + ): Promise { + const installation = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + const isSolo = installation?.mode === 'Solo'; + if (expectedSolo !== undefined && isSolo !== expectedSolo) + throw new ConflictException( + 'Operating mode changed; reload before booking', + ); + if (isSolo && installation?.ownerEmployeeId !== employeeId) + throw new ForbiddenException( + 'Solo access requires the installation owner', + ); + if (isSolo) { + await this.installation.requireOwner(actorId, tx); + if (actorId !== employeeId) + throw new ForbiddenException( + 'Solo writes are limited to your own entries', + ); + } else if (actorId !== employeeId) { + const actor = await tx.employee.findUnique({ + where: { id: actorId }, + select: { isActive: true, role: true }, + }); + if ( + !actor?.isActive || + (actor.role !== 'Manager' && actor.role !== 'HRAdmin') + ) + throw new ForbiddenException( + 'Current manager or administrator access is required', + ); + } + const employee = await tx.employee.findUnique({ + where: { id: employeeId }, + select: { isActive: true }, + }); + if (!employee?.isActive) + throw new ForbiddenException('Employee account is not active'); + return isSolo; + } + + private async assertNoOverlap( + tx: Prisma.TransactionClient, + employeeId: string, + from: Date, + to: Date | null, + excludeId?: string, + ): Promise { + const conflict = await tx.timeEntry.findFirst({ + where: { + employeeId, + voidedAt: null, + status: { not: 'Rejected' }, + ...(excludeId ? { id: { not: excludeId } } : {}), + ...(to ? { clockIn: { lt: to } } : {}), + OR: [{ clockOut: null }, { clockOut: { gt: from } }], + }, + select: { id: true }, + }); + if (conflict) + throw new ConflictException('Time interval overlaps an existing entry'); + } + + private async assertTargetStillBookable( + tx: Prisma.TransactionClient, + target: BookingTarget, + ): Promise { + if (!target.projectId) return; + const initial = await tx.project.findUnique({ + where: { id: target.projectId }, + select: { customerId: true }, + }); + if (!initial) throw new NotFoundException('Project no longer exists'); + if (initial.customerId) + await tx.$queryRaw`SELECT id FROM "Customer" WHERE id = ${initial.customerId}::uuid FOR UPDATE`; + await tx.$queryRaw`SELECT id FROM "Project" WHERE id = ${target.projectId}::uuid FOR UPDATE`; + if (target.serviceOrderId) + await tx.$queryRaw`SELECT id FROM "ServiceOrder" WHERE id = ${target.serviceOrderId}::uuid FOR UPDATE`; + const project = await tx.project.findUnique({ + where: { id: target.projectId }, + include: { customer: true }, + }); + if (!project?.isActive || (project.customer && !project.customer.isActive)) + throw new ConflictException( + 'Project or customer was archived; select an active booking target', + ); + if (project.customerId !== initial.customerId) + throw new ConflictException( + 'Project customer changed; reload before booking', + ); + if (target.serviceOrderId) { + const order = await tx.serviceOrder.findUnique({ + where: { id: target.serviceOrderId }, + }); + if (!order?.isActive || order.projectId !== target.projectId) + throw new ConflictException('Service order is no longer bookable'); + } else if ( + await tx.serviceOrder.count({ + where: { projectId: target.projectId, isActive: true }, + }) + ) { + throw new BadRequestException( + 'Select an active service order for this project', + ); + } + } + + private async withCaptureSummaries( + rows: TimeEntry[], + ): Promise { + const groups = rows + .filter((row) => row.captureGroupId) + .map((row) => ({ + employeeId: row.employeeId, + captureGroupId: row.captureGroupId, + })); + const siblings = groups.length + ? await this.prisma.timeEntry.findMany({ where: { OR: groups } }) + : []; + const summaries = calculateCaptureSummaries([ + ...new Map([...rows, ...siblings].map((row) => [row.id, row])).values(), + ]); + return rows.map((row) => ({ + ...toTimeEntryDto(row), + summary: summaries.get(row.id) ?? null, + })); + } + + private async auditSnapshot( + tx: Prisma.TransactionClient, + entry: TimeEntry, + ): Promise { + const group = entry.captureGroupId + ? await tx.timeEntry.findMany({ + where: { + employeeId: entry.employeeId, + captureGroupId: entry.captureGroupId, + }, + }) + : [entry]; + const summaries = calculateCaptureSummaries(group); + const summary = summaries.get(entry.id) ?? null; + const captureSummary = [...summaries.values()].reduce( + (sum, item) => ({ + grossMinutes: sum.grossMinutes + item.grossMinutes, + breakMinutes: sum.breakMinutes + item.breakMinutes, + netMinutes: sum.netMinutes + item.netMinutes, + }), + { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 }, + ); + return { + clockIn: entry.clockIn.toISOString(), + clockOut: entry.clockOut?.toISOString() ?? null, + projectId: entry.projectId, + serviceOrderId: entry.serviceOrderId, + activity: entry.activity, + note: entry.note, + billable: entry.billable, + revision: entry.revision, + status: entry.status, + requiresApproval: entry.requiresApproval, + approvalMode: entry.approvalMode, + captureGroupId: entry.captureGroupId, + breakRules: parseBreakRules(entry.breakRules), + voidedAt: entry.voidedAt?.toISOString() ?? null, + bookingDate: entry.bookingDate?.toISOString() ?? null, + summary: summary ? { ...summary } : null, + captureSummary: { ...captureSummary }, + }; + } + + private async writeAudit( + tx: Prisma.TransactionClient, + entry: TimeEntry, + actorId: string, + action: string, + before: Prisma.InputJsonObject | null, + reason: string | null, + ): Promise { + await tx.timeEntryAudit.create({ + data: { + timeEntryId: entry.id, + actorId, + action, + before: before ?? Prisma.DbNull, + after: await this.auditSnapshot(tx, entry), + reason, + }, + }); } private assertSelfOrAdmin(employeeId: string, user: JwtUser): void { @@ -1135,6 +1824,7 @@ export class TimeEntriesService { tx: Prisma.TransactionClient, employeeId: string, ): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`; } diff --git a/apps/api/src/generate-openapi.ts b/apps/api/src/generate-openapi.ts index 26abbf8..59a9007 100644 --- a/apps/api/src/generate-openapi.ts +++ b/apps/api/src/generate-openapi.ts @@ -33,7 +33,7 @@ async function main() { .setDescription( 'Self-hostable working-time tracker — REST + WebSocket surface.', ) - .setVersion('1.4.0') + .setVersion('2.0.0') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index c7d7e04..ee73829 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -38,7 +38,7 @@ async function bootstrap() { .setDescription( 'Self-hostable working-time tracker — REST + WebSocket surface.', ) - .setVersion('1.4.0') + .setVersion('2.0.0') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index fc7d47d..3cf2bc7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -766,7 +766,7 @@ async function attempt( return fetch(`${baseUrl}${path}`, { ...init, headers }); } -async function request(path: string, init?: RequestInit): Promise { +export async function request(path: string, init?: RequestInit): Promise { let response = await attempt(path, init, readToken()); // 401 → try to refresh once, then retry the original request. @@ -802,6 +802,31 @@ async function request(path: string, init?: RequestInit): Promise { return (await response.json()) as T; } +export async function downloadAuthenticated( + path: string, + fileName: string, +): Promise { + const fetchOnce = (token: string | null) => + fetch(`${baseUrl}${path}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + let response = await fetchOnce(readToken()); + if (response.status === 401) { + const fresh = await tryRefreshOnce(); + if (fresh) response = await fetchOnce(fresh); + } + if (!response.ok) + throw new ApiError(response.status, `Download failed (${response.status})`); + const url = URL.createObjectURL(await response.blob()); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + export const api = { health: () => request('/api/health'), login: (payload: LoginPayload) => diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index b4802ea..af9ee0c 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -4,7 +4,39 @@ */ export interface paths { - '/api/auth/login': { + '/api/installation/hints': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['InstallationController_hints']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['InstallationController_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation/settings': { parameters: { query?: never; header?: never; @@ -13,14 +45,30 @@ export interface paths { }; get?: never; put?: never; - post: operations['AuthController_login']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations['InstallationController_settings']; + trace?: never; + }; + '/api/installation/complete-setup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['InstallationController_complete']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/auth/refresh': { + '/api/installation/mode-preview': { parameters: { query?: never; header?: never; @@ -29,13 +77,141 @@ export interface paths { }; get?: never; put?: never; - post: operations['AuthController_refresh']; + post: operations['InstallationController_preview']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation/mode': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['InstallationController_mode']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation/days': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['InstallationController_days']; + put?: never; + post: operations['InstallationController_createDay']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation/days/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations['InstallationController_cancelDay']; + options?: never; + head?: never; + patch: operations['InstallationController_updateDay']; + trace?: never; + }; + '/api/installation/events': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['InstallationController_events']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation/days/{id}/audit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['InstallationController_dayAudit']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/installation/summary': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['InstallationController_totals']; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; + '/api/customers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['CustomersController_list']; + put?: never; + post: operations['CustomersController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/customers/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['CustomersController_get']; + put: operations['CustomersController_update']; + post?: never; + delete: operations['CustomersController_remove']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/auth/me': { parameters: { query?: never; @@ -49,6 +225,54 @@ export interface paths { delete?: never; options?: never; head?: never; + patch: operations['AuthController_profile']; + trace?: never; + }; + '/api/auth/password': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['AuthController_password']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/auth/login': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['AuthController_login']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/auth/refresh': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['AuthController_refresh']; + delete?: never; + options?: never; + head?: never; patch?: never; trace?: never; }; @@ -340,6 +564,38 @@ export interface paths { patch?: never; trace?: never; }; + '/api/reports/solo': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['ReportsController_solo']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/reports/solo.csv': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['ReportsController_soloCsv']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/reports/working-times/employees': { parameters: { query?: never; @@ -468,7 +724,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/timeentries/{id}': { + '/api/timeentries/manual': { parameters: { query?: never; header?: never; @@ -477,14 +733,14 @@ export interface paths { }; get?: never; put?: never; - post?: never; + post: operations['TimeEntriesController_manual']; delete?: never; options?: never; head?: never; - patch: operations['TimeEntriesController_update']; + patch?: never; trace?: never; }; - '/api/timeentries/{id}/split': { + '/api/timeentries/{id}/correct': { parameters: { query?: never; header?: never; @@ -493,30 +749,30 @@ export interface paths { }; get?: never; put?: never; - post: operations['TimeEntriesController_split']; + post?: never; delete?: never; options?: never; head?: never; - patch?: never; + patch: operations['TimeEntriesController_correct']; trace?: never; }; - '/api/terminals/support-prompt': { + '/api/timeentries/{id}/void': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['TerminalsController_supportPrompt']; + get?: never; put?: never; - post?: never; + post: operations['TimeEntriesController_voidEntry']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/terminals/support-prompt/dismiss': { + '/api/timeentries/{id}/switch-project': { parameters: { query?: never; header?: never; @@ -525,46 +781,46 @@ export interface paths { }; get?: never; put?: never; - post: operations['TerminalsController_dismissSupportPrompt']; + post: operations['TimeEntriesController_switchProject']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/terminals/pair': { + '/api/timeentries/{id}/audit': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations['TimeEntriesController_audit']; put?: never; - post: operations['TerminalsController_pair']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/terminals/kiosk': { + '/api/timeentries/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['TerminalsController_kiosk']; + get?: never; put?: never; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + patch: operations['TimeEntriesController_update']; trace?: never; }; - '/api/terminals/scan': { + '/api/timeentries/{id}/split': { parameters: { query?: never; header?: never; @@ -573,14 +829,94 @@ export interface paths { }; get?: never; put?: never; - post: operations['TerminalsController_scan']; + post: operations['TimeEntriesController_split']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/terminals': { + '/api/terminals/support-prompt': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['TerminalsController_supportPrompt']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/terminals/support-prompt/dismiss': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['TerminalsController_dismissSupportPrompt']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/terminals/pair': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['TerminalsController_pair']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/terminals/kiosk': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['TerminalsController_kiosk']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/terminals/scan': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['TerminalsController_scan']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/terminals': { parameters: { query?: never; header?: never; @@ -1112,6 +1448,228 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + PersonalHintDto: { + date: string; + /** @enum {string} */ + kind: + | 'BeforeFrame' + | 'AfterFrame' + | 'LateArrival' + | 'EarlyDeparture' + | 'MidDayGap'; + boundary: string; + deltaMinutes: number; + windowLabel?: string; + }; + PersonalHintsDto: { + enabled: boolean; + hints: components['schemas']['PersonalHintDto'][]; + }; + SoloCapabilitiesResponse: { + isOwner: boolean; + solo: boolean; + targets: boolean; + leave: boolean; + coreTimeHints: boolean; + dailyBlock: boolean; + gps: boolean; + }; + SoloBreakRuleResponse: { + afterMinutes: number; + breakMinutes: number; + }; + SoloCoreWindowResponse: { + start: string; + end: string; + weekdays: number; + label?: Record | null; + }; + SoloPolicyResponse: { + id: string | null; + /** Format: date */ + effectiveFrom: string | null; + targetEnabled: boolean; + weeklyTargetMinutes: number | null; + workingDays: number; + leaveEnabled: boolean; + annualLeaveDays: number; + carryOverDays: number; + /** Format: date */ + carryOverExpiresOn: string | null; + leaveAdjustmentDays: number; + leaveAdjustmentReason: string | null; + leaveAllowanceYear: number; + holidayCalendar: string; + holidayDates: string[]; + breakRules: components['schemas']['SoloBreakRuleResponse'][]; + coreTimeHintsEnabled: boolean; + dailyBlockEnabled: boolean; + gpsEnabled: boolean; + frameStart: string; + frameEnd: string; + coreTimes: components['schemas']['SoloCoreWindowResponse'][]; + }; + InstallationStateResponse: { + /** @enum {string} */ + mode: 'Team' | 'Solo'; + ownerEmployeeId: string | null; + setupCompleted: boolean; + revision: number; + timeZone: string; + capabilities: components['schemas']['SoloCapabilitiesResponse']; + policy: components['schemas']['SoloPolicyResponse']; + futurePolicies: components['schemas']['SoloPolicyResponse'][]; + }; + SoloBreakRuleDto: { + afterMinutes: number; + breakMinutes: number; + }; + SoloCoreWindowDto: { + start: string; + end: string; + weekdays: number; + label?: string; + }; + UpdateSoloSettingsDto: { + revision: number; + /** @example 2026-09-08 */ + effectiveFrom: string; + targetEnabled: boolean; + weeklyTargetMinutes?: Record | null; + workingDays: number; + leaveEnabled: boolean; + annualLeaveDays: number; + carryOverDays?: number; + carryOverExpiresOn?: Record | null; + leaveAdjustmentDays?: number; + leaveAdjustmentReason?: Record | null; + leaveAllowanceYear?: number; + holidayCalendar: string; + holidayDates: string[]; + breakRules: components['schemas']['SoloBreakRuleDto'][]; + coreTimeHintsEnabled: boolean; + frameStart?: string; + frameEnd?: string; + coreTimes?: components['schemas']['SoloCoreWindowDto'][]; + dailyBlockEnabled: boolean; + gpsEnabled: boolean; + }; + PreviewModeDto: { + /** @enum {string} */ + mode: 'Team' | 'Solo'; + }; + ModePreviewResponse: { + allowed: boolean; + blockers: string[]; + }; + ChangeModeDto: { + /** @enum {string} */ + mode: 'Team' | 'Solo'; + revision: number; + }; + PersonalDayResponse: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: 'Free' | 'Vacation' | 'Sickness' | 'Training'; + /** Format: date */ + from: string; + /** Format: date */ + to: string; + note: string | null; + halfDayStart: boolean; + halfDayEnd: boolean; + revision: number; + /** Format: date-time */ + cancelledAt: string | null; + }; + PersonalDayDto: { + /** @enum {string} */ + kind: 'Free' | 'Vacation' | 'Sickness' | 'Training'; + from: string; + to: string; + note?: Record | null; + halfDayStart?: boolean; + halfDayEnd?: boolean; + }; + EditPersonalDayDto: { + /** @enum {string} */ + kind: 'Free' | 'Vacation' | 'Sickness' | 'Training'; + from: string; + to: string; + note?: Record | null; + halfDayStart?: boolean; + halfDayEnd?: boolean; + revision: number; + }; + RevisionDto: { + revision: number; + }; + InstallationAuditResponse: { + /** Format: uuid */ + id: string; + actorId: string | null; + action: string; + before: { + [key: string]: unknown; + } | null; + after: { + [key: string]: unknown; + } | null; + /** Format: date-time */ + occurredAt: string; + }; + PersonalSummaryResponse: { + /** Format: date */ + from: string; + /** Format: date */ + to: string; + timeZone: string; + targetEnabled: boolean; + leaveEnabled: boolean; + actualMinutes: number; + /** @description Actual net minutes on dates with an enabled Solo target. */ + targetActualMinutes: number | null; + targetMinutes: number | null; + overtimeMinutes: number | null; + vacationDaysTotal: number | null; + vacationDaysUsed: number | null; + vacationDaysRemaining: number | null; + vacationAllowanceYear: number; + vacationDaysCarryOver: number | null; + vacationDaysCarryOverUsed: number | null; + vacationDaysCarryOverExpired: number | null; + vacationDaysAdjustment: number | null; + }; + CustomerDto: { + /** Format: uuid */ + id: string; + name: string; + code: string | null; + note: string | null; + isActive: boolean; + projectCount: number; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + UpsertCustomerDto: { + name: string; + code?: Record | null; + note?: Record | null; + /** @default true */ + isActive: boolean; + }; + UpdateOwnProfileDto: { + firstName: string; + lastName: string; + email: string; + }; + ChangePasswordDto: { + currentPassword: string; + newPassword: string; + }; LoginDto: { /** @example hannah.roth@openclockwork.test */ email: string; @@ -1136,7 +1694,7 @@ export interface components { status: string; /** @example openclockwork-api */ service: string; - /** @example 1.4.0 */ + /** @example 2.0.0 */ version: string; /** Format: date-time */ utcTimestamp: string; @@ -1373,6 +1931,56 @@ export interface components { /** @default false */ overrideExisting: boolean; }; + ServiceOrderDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + orderNo: string; + title: string; + isActive: boolean; + planHours: number | null; + bookedMinutes: number; + defaultBillable: boolean | null; + bookedNetMinutes?: number; + }; + ProjectDto: { + /** Format: uuid */ + id: string; + code: string; + name: string; + description: string | null; + isActive: boolean; + planHours: number | null; + /** Format: uuid */ + customerId: string | null; + customerName: string | null; + defaultBillable: boolean; + bookedNetMinutes?: number; + bookedMinutes: number; + serviceOrders: components['schemas']['ServiceOrderDto'][]; + assignedEmployeeCount: number; + /** Format: date-time */ + updatedAt: string; + }; + BookableServiceOrderDto: { + /** Format: uuid */ + id: string; + orderNo: string; + title: string; + defaultBillable: boolean | null; + }; + BookableProjectDto: { + /** Format: uuid */ + id: string; + code: string; + name: string; + /** Format: uuid */ + customerId: string | null; + customerName: string | null; + defaultBillable: boolean; + serviceOrders: components['schemas']['BookableServiceOrderDto'][]; + }; UpsertProjectDto: { /** @example PRJ-001 */ code: string; @@ -1381,6 +1989,10 @@ export interface components { /** @default true */ isActive: boolean; planHours?: Record | null; + /** Format: uuid */ + customerId?: Record | null; + /** @default false */ + defaultBillable: boolean; }; UpsertServiceOrderDto: { /** @example SA-2026-001 */ @@ -1389,6 +2001,54 @@ export interface components { /** @default true */ isActive: boolean; planHours?: Record | null; + /** @description Null inherits the project default. */ + defaultBillable?: Record | null; + }; + SoloReportRowDto: { + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + billableNetMinutes: number; + /** Format: uuid */ + id: string; + /** Format: date */ + date: string; + /** Format: date-time */ + clockIn: string; + /** Format: date-time */ + clockOut: string; + /** Format: uuid */ + customerId: string | null; + customerName: string | null; + /** Format: uuid */ + projectId: string | null; + projectCode: string | null; + projectName: string | null; + /** Format: uuid */ + serviceOrderId: string | null; + orderNo: string | null; + orderTitle: string | null; + activity: string | null; + billable: boolean; + }; + SoloReportTotalsDto: { + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + billableNetMinutes: number; + }; + SoloReportDto: { + /** Format: date */ + from: string; + /** Format: date */ + to: string; + timeZone: string; + /** @enum {string} */ + timeDefinition: 'net_working_time'; + rows: components['schemas']['SoloReportRowDto'][]; + totals: components['schemas']['SoloReportTotalsDto']; + /** @description Matching open timers, excluded from totals and customer statements. */ + openTimerCount: number; }; WorkingTimeReportEmployeeDto: { /** Format: uuid */ @@ -1439,30 +2099,106 @@ export interface components { rows: components['schemas']['WorkingTimeReportRowDto'][]; totals: components['schemas']['WorkingTimeReportTotalsDto']; }; - ClockInDto: { - /** - * Format: uuid - * @deprecated - * @description Ignored. The employee identity is always taken from the JWT. - */ - employeeId?: string; - latitude?: number | null; - longitude?: number | null; - accuracyMeters?: number | null; + TimeSummaryDto: { + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + }; + TimeEntryDto: { + note: string | null; + revision: number; + billable: boolean; + /** Format: date-time */ + voidedAt: string | null; /** Format: uuid */ - projectId?: string | null; + captureGroupId: string | null; + /** @enum {string|null} */ + approvalMode: 'Solo' | 'Team' | null; /** Format: uuid */ - serviceOrderId?: string | null; - activity?: string | null; - }; - ClockOutDto: { - /** - * Format: uuid - * @deprecated - * @description Ignored. The employee identity is always taken from the JWT. - */ - employeeId?: string; - latitude?: number | null; + id: string; + /** Format: uuid */ + employeeId: string; + /** Format: date-time */ + clockIn: string; + /** Format: date-time */ + clockOut: string | null; + /** @enum {string} */ + source: 'Manual' | 'Pwa' | 'Terminal' | 'Erp' | 'DailyBlock'; + /** @enum {string} */ + status: 'Open' | 'Pending' | 'Approved' | 'Rejected'; + requiresApproval: boolean; + latitude: number | null; + longitude: number | null; + accuracyMeters: number | null; + terminalDistanceMeters: number | null; + /** @description Terminal geofence radius that was valid when clock-in was accepted. */ + terminalRadiusMeters: number | null; + /** @description Terminal maximum GPS accuracy that was valid when clock-in was accepted. */ + terminalMaxAccuracyMeters: number | null; + /** Format: date-time */ + positionTimestamp: string | null; + clockOutLatitude: number | null; + clockOutLongitude: number | null; + clockOutAccuracyMeters: number | null; + clockOutTerminalDistanceMeters: number | null; + /** @description Terminal geofence radius that was valid when clock-out was accepted. */ + clockOutTerminalRadiusMeters: number | null; + /** @description Terminal maximum GPS accuracy that was valid when clock-out was accepted. */ + clockOutTerminalMaxAccuracyMeters: number | null; + /** Format: date-time */ + clockOutPositionTimestamp: string | null; + /** Format: uuid */ + terminalId: string | null; + /** Format: uuid */ + clockOutTerminalId: string | null; + /** Format: uuid */ + clockInChallengeId: string | null; + /** Format: uuid */ + clockOutChallengeId: string | null; + /** Format: uuid */ + projectId: string | null; + projectCode: string | null; + projectName: string | null; + /** Format: uuid */ + serviceOrderId: string | null; + serviceOrderNo: string | null; + serviceOrderTitle: string | null; + activity: string | null; + summary: components['schemas']['TimeSummaryDto'] | null; + }; + ClockInDto: { + note?: string | null; + billable?: boolean; + /** + * Format: uuid + * @deprecated + * @description Ignored. The employee identity is always taken from the JWT. + */ + employeeId?: string; + latitude?: number | null; + longitude?: number | null; + accuracyMeters?: number | null; + /** Format: uuid */ + projectId?: string | null; + /** Format: uuid */ + serviceOrderId?: string | null; + activity?: string | null; + }; + ClockOutDto: { + /** + * Format: uuid + * @description Required in Solo mode to identify the timer being stopped. + */ + id?: string; + /** @description Required in Solo mode. */ + revision?: number; + /** + * Format: uuid + * @deprecated + * @description Ignored. The employee identity is always taken from the JWT. + */ + employeeId?: string; + latitude?: number | null; longitude?: number | null; accuracyMeters?: number | null; }; @@ -1476,6 +2212,8 @@ export interface components { workdayCount: number; }; CreateDailyBlockDto: { + note?: string | null; + billable?: boolean; /** @example 2026-08-13 */ date: string; /** @example 08:00 */ @@ -1486,7 +2224,15 @@ export interface components { serviceOrderId?: string | null; activity?: string | null; }; + EntryRevisionDto: { + /** Format: uuid */ + id: string; + revision: number; + }; BookProjectRangeDto: { + /** @description Required for Solo. Exact IDs/revisions of all entries intersecting the range. */ + revisions?: components['schemas']['EntryRevisionDto'][]; + billable?: boolean; /** Format: uuid */ employeeId: string; /** Format: date-time */ @@ -1499,7 +2245,113 @@ export interface components { serviceOrderId?: string | null; activity?: string | null; }; + BookProjectRangeResult: { + entries: components['schemas']['TimeEntryDto'][]; + }; + ManualTimeEntryDto: { + note?: string | null; + billable?: boolean; + /** + * Format: uuid + * @deprecated + * @description Ignored. The employee identity is always taken from the JWT. + */ + employeeId?: string; + latitude?: number | null; + longitude?: number | null; + accuracyMeters?: number | null; + /** Format: uuid */ + projectId?: string | null; + /** Format: uuid */ + serviceOrderId?: string | null; + activity?: string | null; + /** + * Format: date-time + * @description Absolute instant with UTC Z or explicit offset. + */ + clockIn: string; + /** + * Format: date-time + * @description Absolute instant with UTC Z or explicit offset. + */ + clockOut: string; + }; + CorrectTimeEntryDto: { + note?: string | null; + billable?: boolean; + /** + * Format: uuid + * @deprecated + * @description Ignored. The employee identity is always taken from the JWT. + */ + employeeId?: string; + latitude?: number | null; + longitude?: number | null; + accuracyMeters?: number | null; + /** Format: uuid */ + projectId?: string | null; + /** Format: uuid */ + serviceOrderId?: string | null; + activity?: string | null; + /** + * Format: date-time + * @description Absolute instant with UTC Z or explicit offset. + */ + clockIn: string; + /** + * Format: date-time + * @description Absolute instant with UTC Z or explicit offset. + */ + clockOut: string; + revision: number; + reason: string; + }; + VoidTimeEntryDto: { + revision: number; + reason: string; + }; + SwitchProjectDto: { + note?: string | null; + billable?: boolean; + /** + * Format: uuid + * @deprecated + * @description Ignored. The employee identity is always taken from the JWT. + */ + employeeId?: string; + latitude?: number | null; + longitude?: number | null; + accuracyMeters?: number | null; + /** Format: uuid */ + projectId?: string | null; + /** Format: uuid */ + serviceOrderId?: string | null; + activity?: string | null; + revision: number; + }; + SplitTimeEntryResult: { + first: components['schemas']['TimeEntryDto']; + second: components['schemas']['TimeEntryDto']; + }; + TimeEntryAuditDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + timeEntryId: string; + /** Format: uuid */ + actorId: string | null; + action: string; + before: Record | null; + after: Record | null; + reason: string | null; + /** Format: date-time */ + occurredAt: string; + }; UpdateTimeEntryDto: { + note?: string | null; + /** @description Required for Solo entries; prevents lost updates. */ + revision?: number; + billable?: boolean; /** Format: uuid */ projectId?: string | null; /** Format: uuid */ @@ -1507,6 +2359,9 @@ export interface components { activity?: string | null; }; SplitTimeEntryDto: { + /** @description Required for Solo entries. */ + revision?: number; + billable?: boolean; /** Format: date-time */ at: string; /** Format: uuid */ @@ -1566,64 +2421,6 @@ export interface components { */ positionTimestamp?: string; }; - TimeSummaryDto: { - grossMinutes: number; - breakMinutes: number; - netMinutes: number; - }; - TimeEntryDto: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - employeeId: string; - /** Format: date-time */ - clockIn: string; - /** Format: date-time */ - clockOut: string | null; - /** @enum {string} */ - source: 'Manual' | 'Pwa' | 'Terminal' | 'Erp' | 'DailyBlock'; - /** @enum {string} */ - status: 'Open' | 'Pending' | 'Approved' | 'Rejected'; - requiresApproval: boolean; - latitude: number | null; - longitude: number | null; - accuracyMeters: number | null; - terminalDistanceMeters: number | null; - /** @description Terminal geofence radius that was valid when clock-in was accepted. */ - terminalRadiusMeters: number | null; - /** @description Terminal maximum GPS accuracy that was valid when clock-in was accepted. */ - terminalMaxAccuracyMeters: number | null; - /** Format: date-time */ - positionTimestamp: string | null; - clockOutLatitude: number | null; - clockOutLongitude: number | null; - clockOutAccuracyMeters: number | null; - clockOutTerminalDistanceMeters: number | null; - /** @description Terminal geofence radius that was valid when clock-out was accepted. */ - clockOutTerminalRadiusMeters: number | null; - /** @description Terminal maximum GPS accuracy that was valid when clock-out was accepted. */ - clockOutTerminalMaxAccuracyMeters: number | null; - /** Format: date-time */ - clockOutPositionTimestamp: string | null; - /** Format: uuid */ - terminalId: string | null; - /** Format: uuid */ - clockOutTerminalId: string | null; - /** Format: uuid */ - clockInChallengeId: string | null; - /** Format: uuid */ - clockOutChallengeId: string | null; - /** Format: uuid */ - projectId: string | null; - projectCode: string | null; - projectName: string | null; - /** Format: uuid */ - serviceOrderId: string | null; - serviceOrderNo: string | null; - serviceOrderTitle: string | null; - activity: string | null; - summary: components['schemas']['TimeSummaryDto'] | null; - }; ScanTerminalResultDto: { /** @enum {string} */ action: 'clock-in' | 'clock-out'; @@ -1824,43 +2621,473 @@ export interface components { ids: string[]; note: string; }; - CreateAbsenceDto: { - /** Format: uuid */ - employeeId: string; - /** - * @default Sickness - * @enum {string} - */ - kind: 'Sickness' | 'Training' | 'Flextime'; - /** Format: date-time */ - from: string; - /** Format: date-time */ - to: string; - /** - * @description Sickness only: ärztliches Attest vorgelegt. - * @default false - */ - certified: boolean; - note?: Record | null; + CreateAbsenceDto: { + /** Format: uuid */ + employeeId: string; + /** + * @default Sickness + * @enum {string} + */ + kind: 'Sickness' | 'Training' | 'Flextime'; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + /** + * @description Sickness only: ärztliches Attest vorgelegt. + * @default false + */ + certified: boolean; + note?: Record | null; + }; + UpdateAbsenceDto: { + /** Format: date-time */ + from?: string; + /** Format: date-time */ + to?: string; + certified?: boolean; + note?: Record | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + InstallationController_hints: { + parameters: { + query: { + from: string; + to: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PersonalHintsDto']; + }; + }; + }; + }; + InstallationController_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['InstallationStateResponse']; + }; + }; + }; + }; + InstallationController_settings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateSoloSettingsDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['InstallationStateResponse']; + }; + }; + }; + }; + InstallationController_complete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['InstallationStateResponse']; + }; + }; + }; + }; + InstallationController_preview: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PreviewModeDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ModePreviewResponse']; + }; + }; + }; + }; + InstallationController_mode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChangeModeDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['InstallationStateResponse']; + }; + }; + }; + }; + InstallationController_days: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PersonalDayResponse'][]; + }; + }; + }; + }; + InstallationController_createDay: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PersonalDayDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PersonalDayResponse']; + }; + }; + }; + }; + InstallationController_cancelDay: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['RevisionDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PersonalDayResponse']; + }; + }; + }; + }; + InstallationController_updateDay: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['EditPersonalDayDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PersonalDayResponse']; + }; + }; + }; + }; + InstallationController_events: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['InstallationAuditResponse'][]; + }; + }; + }; + }; + InstallationController_dayAudit: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['InstallationAuditResponse'][]; + }; + }; + }; + }; + InstallationController_totals: { + parameters: { + query: { + from: string; + to: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PersonalSummaryResponse']; + }; + }; + }; + }; + CustomersController_list: { + parameters: { + query: { + includeInactive: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CustomerDto'][]; + }; + }; + }; + }; + CustomersController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpsertCustomerDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CustomerDto']; + }; + }; + }; + }; + CustomersController_get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CustomerDto']; + }; + }; + }; + }; + CustomersController_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpsertCustomerDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CustomerDto']; + }; + }; + }; + }; + CustomersController_remove: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_profile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - UpdateAbsenceDto: { - /** Format: date-time */ - from?: string; - /** Format: date-time */ - to?: string; - certified?: boolean; - note?: Record | null; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateOwnProfileDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - AuthController_login: { + AuthController_password: { parameters: { query?: never; header?: never; @@ -1869,7 +3096,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['LoginDto']; + 'application/json': components['schemas']['ChangePasswordDto']; }; }; responses: { @@ -1881,7 +3108,7 @@ export interface operations { }; }; }; - AuthController_refresh: { + AuthController_login: { parameters: { query?: never; header?: never; @@ -1890,7 +3117,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['RefreshDto']; + 'application/json': components['schemas']['LoginDto']; }; }; responses: { @@ -1902,14 +3129,18 @@ export interface operations { }; }; }; - AuthController_me: { + AuthController_refresh: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['RefreshDto']; + }; + }; responses: { 200: { headers: { @@ -2262,7 +3493,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ProjectDto'][]; + }; }; }; }; @@ -2283,7 +3516,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ProjectDto']; + }; }; }; }; @@ -2319,7 +3554,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['BookableProjectDto'][]; + }; }; }; }; @@ -2338,7 +3575,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ProjectDto']; + }; }; }; }; @@ -2361,7 +3600,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ProjectDto']; + }; }; }; }; @@ -2425,7 +3666,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ServiceOrderDto']; + }; }; }; }; @@ -2449,7 +3692,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['ServiceOrderDto']; + }; }; }; }; @@ -2513,6 +3758,63 @@ export interface operations { }; }; }; + ReportsController_solo: { + parameters: { + query: { + from: string; + to: string; + customerId?: string; + projectId?: string; + serviceOrderId?: string; + billable?: 'true' | 'false'; + /** @description True selects only time without a project. */ + unassigned?: 'true' | 'false'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SoloReportDto']; + }; + }; + }; + }; + ReportsController_soloCsv: { + parameters: { + query: { + from: string; + to: string; + customerId?: string; + projectId?: string; + serviceOrderId?: string; + billable?: 'true' | 'false'; + /** @description True selects only time without a project. */ + unassigned?: 'true' | 'false'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description UTF-8 BOM, semicolon-delimited CSV. Exact minutes; metadata rows state timezone and time definition. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/csv': string; + }; + }; + }; + }; ReportsController_workingTimeEmployees: { parameters: { query?: never; @@ -2573,7 +3875,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['TimeEntryDto'][]; + }; }; }; }; @@ -2594,7 +3898,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; }; }; }; @@ -2615,13 +3921,17 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; }; }; }; TimeEntriesController_dailyBlockOption: { parameters: { - query?: never; + query: { + date: string; + }; header?: never; path?: never; cookie?: never; @@ -2655,7 +3965,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; }; }; }; @@ -2676,7 +3988,128 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['BookProjectRangeResult']; + }; + }; + }; + }; + TimeEntriesController_manual: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ManualTimeEntryDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; + }; + }; + }; + TimeEntriesController_correct: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CorrectTimeEntryDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; + }; + }; + }; + TimeEntriesController_voidEntry: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['VoidTimeEntryDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; + }; + }; + }; + TimeEntriesController_switchProject: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['SwitchProjectDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SplitTimeEntryResult']; + }; + }; + }; + }; + TimeEntriesController_audit: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryAuditDto'][]; + }; }; }; }; @@ -2699,7 +4132,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['TimeEntryDto']; + }; }; }; }; @@ -2722,7 +4157,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['SplitTimeEntryResult']; + }; }; }; }; diff --git a/apps/web/src/api/solo.ts b/apps/web/src/api/solo.ts new file mode 100644 index 0000000..76ece69 --- /dev/null +++ b/apps/web/src/api/solo.ts @@ -0,0 +1,269 @@ +import { + request, + type ProjectDto, + type ServiceOrderDto, + type TimeEntryDto, +} from './client'; + +export interface SoloPolicy { + id: string | null; + effectiveFrom: string; + targetEnabled: boolean; + weeklyTargetMinutes: number | null; + workingDays: number; + leaveEnabled: boolean; + annualLeaveDays: number; + carryOverDays: number; + carryOverExpiresOn: string | null; + leaveAdjustmentDays: number; + leaveAdjustmentReason: string | null; + leaveAllowanceYear: number; + holidayCalendar: string; + holidayDates: string[]; + breakRules: { afterMinutes: number; breakMinutes: number }[]; + coreTimeHintsEnabled: boolean; + frameStart: string; + frameEnd: string; + coreTimes: { start: string; end: string; weekdays: number; label?: string }[]; + dailyBlockEnabled: boolean; + gpsEnabled: boolean; +} +export interface Installation { + mode: 'Team' | 'Solo'; + ownerEmployeeId: string | null; + setupCompleted: boolean; + revision: number; + timeZone: string; + capabilities: { + isOwner: boolean; + solo: boolean; + targets: boolean; + leave: boolean; + coreTimeHints: boolean; + dailyBlock: boolean; + gps: boolean; + }; + policy: SoloPolicy; + futurePolicies?: SoloPolicy[]; +} +export interface SoloEntry extends TimeEntryDto { + note: string | null; + revision: number; + billable: boolean; + voidedAt: string | null; + captureGroupId: string | null; + approvalMode: string; +} +export interface Customer { + id: string; + name: string; + code: string | null; + note: string | null; + isActive: boolean; + projectCount: number; +} +export interface SoloOrder extends ServiceOrderDto { + defaultBillable: boolean | null; + bookedNetMinutes?: number; +} +export interface SoloProject extends ProjectDto { + customerId: string | null; + customerName: string | null; + defaultBillable: boolean; + bookedNetMinutes?: number; + serviceOrders: SoloOrder[]; +} +export interface PersonalDay { + id: string; + kind: 'Free' | 'Vacation' | 'Sickness' | 'Training'; + from: string; + to: string; + note: string | null; + halfDayStart: boolean; + halfDayEnd: boolean; + revision: number; + cancelledAt: string | null; +} +export interface SoloSummary { + targetEnabled: boolean; + leaveEnabled: boolean; + targetMinutes: number | null; + actualMinutes: number; + overtimeMinutes: number | null; + vacationDaysTotal: number | null; + vacationDaysUsed: number | null; + vacationDaysRemaining: number | null; + from: string; + to: string; + timeZone: string; +} +export interface ReportRow { + id: string; + date: string; + clockIn: string; + clockOut: string; + customerId: string | null; + customerName: string | null; + projectId: string | null; + projectCode: string | null; + projectName: string | null; + serviceOrderId: string | null; + orderNo: string | null; + orderTitle: string | null; + activity: string | null; + billable: boolean; + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + billableNetMinutes: number; +} +export interface SoloReport { + from: string; + to: string; + timeZone: string; + timeDefinition: string; + rows: ReportRow[]; + totals: { + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + billableNetMinutes: number; + }; + openTimerCount: number; +} +export interface AuditEvent { + id: string; + action: string; + occurredAt: string; + reason: string | null; + before: unknown; + after: unknown; +} + +export const soloApi = { + installation: () => request('/api/installation'), + profile: (payload: { firstName: string; lastName: string; email: string }) => + send<{ firstName: string; lastName: string; email: string }>( + '/api/auth/me', + 'PATCH', + payload, + ), + settings: (payload: Omit & { revision: number }) => + send('/api/installation/settings', 'PATCH', payload), + completeSetup: () => + send('/api/installation/complete-setup', 'POST', {}), + modePreview: (mode: Installation['mode']) => + send<{ allowed: boolean; blockers: string[] }>( + '/api/installation/mode-preview', + 'POST', + { mode }, + ), + mode: (mode: Installation['mode'], revision: number) => + send('/api/installation/mode', 'POST', { mode, revision }), + password: (currentPassword: string, newPassword: string) => + send('/api/auth/password', 'POST', { currentPassword, newPassword }), + summary: (from: string, to: string) => + request( + `/api/installation/summary?${new URLSearchParams({ from, to })}`, + ), + entries: (employeeId: string) => + request( + `/api/timeentries?${new URLSearchParams({ employeeId })}`, + ), + start: (payload: object) => + send('/api/timeentries/clock-in', 'POST', payload), + stop: (id: string, revision: number) => + send('/api/timeentries/clock-out', 'POST', { id, revision }), + manual: (payload: object) => + send('/api/timeentries/manual', 'POST', payload), + correct: (id: string, payload: object) => + send(`/api/timeentries/${id}/correct`, 'PATCH', payload), + void: (id: string, revision: number, reason: string) => + send(`/api/timeentries/${id}/void`, 'POST', { + revision, + reason, + }), + switchProject: (id: string, payload: object) => + send<{ first: SoloEntry; second: SoloEntry }>( + `/api/timeentries/${id}/switch-project`, + 'POST', + payload, + ), + split: (id: string, payload: object) => + send<{ first: SoloEntry; second: SoloEntry }>( + `/api/timeentries/${id}/split`, + 'POST', + payload, + ), + audit: (id: string) => request(`/api/timeentries/${id}/audit`), + customers: () => request('/api/customers?includeInactive=true'), + saveCustomer: (id: string | null, payload: object) => + send( + `/api/customers${id ? `/${id}` : ''}`, + id ? 'PUT' : 'POST', + payload, + ), + deleteCustomer: (id: string) => send(`/api/customers/${id}`, 'DELETE'), + projects: () => request('/api/projects?includeInactive=true'), + bookableProjects: (employeeId: string) => + request( + `/api/projects/bookable?${new URLSearchParams({ employeeId })}`, + ), + saveProject: (id: string | null, payload: object) => + send( + `/api/projects${id ? `/${id}` : ''}`, + id ? 'PUT' : 'POST', + payload, + ), + saveOrder: (projectId: string, id: string | null, payload: object) => + send( + `/api/projects/${projectId}/service-orders${id ? `/${id}` : ''}`, + id ? 'PUT' : 'POST', + payload, + ), + days: () => request('/api/installation/days'), + saveDay: (id: string | null, payload: object) => + send( + `/api/installation/days${id ? `/${id}` : ''}`, + id ? 'PATCH' : 'POST', + payload, + ), + cancelDay: (id: string, revision: number) => + send(`/api/installation/days/${id}`, 'DELETE', { revision }), + dayAudit: (id: string) => + request(`/api/installation/days/${id}/audit`), + installationEvents: () => request('/api/installation/events'), + hints: (from: string, to: string) => + request<{ + enabled: boolean; + hints: { + date: string; + kind: + | 'BeforeFrame' + | 'AfterFrame' + | 'LateArrival' + | 'EarlyDeparture' + | 'MidDayGap'; + boundary: string; + deltaMinutes: number; + windowLabel?: string; + }[]; + }>(`/api/installation/hints?${new URLSearchParams({ from, to })}`), + dailyBlock: (payload: object) => + send('/api/timeentries/daily-block', 'POST', payload), + dailyBlockOption: (date: string) => + request<{ + enabled: boolean; + dailyNetMinutes: number; + grossMinutes: number; + breakMinutes: number; + }>(`/api/timeentries/daily-block/option?${new URLSearchParams({ date })}`), + report: (params: URLSearchParams) => + request(`/api/reports/solo?${params}`), +}; +function send(path: string, method: string, payload?: object) { + return request(path, { + method, + ...(payload ? { body: JSON.stringify(payload) } : {}), + }); +} diff --git a/apps/web/src/app/AppShell.spec.tsx b/apps/web/src/app/AppShell.spec.tsx index 942b4aa..69b2b00 100644 --- a/apps/web/src/app/AppShell.spec.tsx +++ b/apps/web/src/app/AppShell.spec.tsx @@ -168,4 +168,46 @@ describe('AppShell', () => { screen.queryByText(`OpenClockwork-Version ${APP_VERSION}`), ).toBeNull(); }); + + it('constrains full Solo bottom labels in five narrow columns without shrinking touch targets', () => { + render( + + + , + ); + const nav = screen.getByRole('navigation', { name: 'Mobile Navigation' }); + expect( + within(nav).getByRole('list').classList.contains('grid-cols-5'), + ).toBe(true); + for (const label of [ + 'Übersicht', + 'Zeiten', + 'Kalender', + 'Berichte', + 'Mehr', + ]) { + const text = within(nav).getByText(label); + expect(text.classList.contains('text-xs')).toBe(true); + expect(text.classList.contains('min-[360px]:text-sm')).toBe(true); + expect(text.classList.contains('leading-5')).toBe(true); + expect(text.classList.contains('max-w-full')).toBe(true); + expect(text.classList.contains('break-words')).toBe(true); + expect(text.parentElement?.classList.contains('py-2.5')).toBe(true); + } + }); + + it('preserves existing Team bottom label styling', () => { + render( + + + , + ); + const nav = screen.getByRole('navigation', { name: 'Mobile Navigation' }); + expect( + within(nav).getByText('Dashboard').classList.contains('text-xs'), + ).toBe(false); + expect(within(nav).getByText('Mehr').classList.contains('text-xs')).toBe( + false, + ); + }); }); diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx index 912ebaa..034bf00 100644 --- a/apps/web/src/app/AppShell.tsx +++ b/apps/web/src/app/AppShell.tsx @@ -22,13 +22,13 @@ import { DemoNotice } from './DemoNotice'; import { BrandMark } from './BrandMark'; import { APP_VERSION } from './app-version'; -export function AppShell() { +export function AppShell({ solo = false }: { solo?: boolean }) { const { user, logout } = useAuth(); const { t, enumLabel } = useI18n(); useRealtimeInvalidation(); const install = useInstallPrompt(); const role = user?.role ?? 'Employee'; - const items = useMemo(() => visibleNavItems(role), [role]); + const items = useMemo(() => visibleNavItems(role, solo), [role, solo]); const bottomItems = useMemo( () => items.filter((i) => i.showInBottomNav), [items], @@ -96,7 +96,9 @@ export function AppShell() { {user.email}

- {t('common.role')}: {enumLabel(user.role)} + {solo + ? t('solo.owner') + : `${t('common.role')}: ${enumLabel(user.role)}`}

) : ( @@ -139,7 +141,8 @@ export function AppShell() { - {location.pathname.startsWith('/admin/') && ( + {(location.pathname.startsWith('/admin/') || + location.pathname === '/settings') && (