# TimeTracking Module — Agent Context ~408 files, ~60k LoC. Hexagonal. One of the largest modules in the monolith. Covers time clocking (entries), day summaries, work schedules, shift management, kiosks (web + face recognition), policies with approval steps, and hour categorization (overtime/etc.) with its own approval flow. **Update this file** for: new services, new/modified flows, new endpoints, new danger zones, new queue messages. Skip routine fixes. --- ## 1. Sub-domains The module is effectively several cooperating sub-domains. When implementing a feature, locate the sub-domain first. | Sub-domain | What it owns | |------------|--------------| | Entries & Day Summaries | Clock-in/out entries, day summary aggregation, incidences (late/absent/underworked), auto-close, public-API entries | | Work Schedules | `WorkSchedule` + `ScheduledDays` — fixed weekly/daily schedules assigned to users | | Shift Management | Shift templates, shift calendar/grid, shift assignments per day, copy & paste, duplicate period, bulk ops + shift rotations CRUD (rotation definitions, not assignments) | | Policies | `TimeTrackingPolicy` + approval-step config + user assignments; emits `TIME_TRACKING_POLICY_ASSIGNED/UNASSIGNED/UPDATED` events | | Kiosks | Kiosk devices (`TimeTrackingKiosk`), link codes (QR pairing), kiosk users, PINs, face assignment | | Hour Categorization | Hour categories (e.g. overtime brackets), rules (expression-like DSL), categorized-hour records with their own approval workflow | | Notification Schedules | Computed reminders to clock in/out based on user schedule + policy | | Settings | Instance-level setting + per-user setting (`TimeTrackingUserSetting`) | | Reports | Day-summary reports (sync + async to email) | --- ## 2. Domain Model ``` TimeTrackingPolicy (auto-close mode, kiosk enabled, notifications, night-shift assignment, hour-category links) ├── TimeTrackingPolicyApprovalStep (position-ordered; responsibles: users / relations / segItems) │ └── Responsibles → User / Relation / SegmentationItem ├── TimeTrackingPolicyHourCategory (policy ↔ HourCategory) └── TimeTrackingPolicyUsers (user ↔ policy) TimeTrackingEntry (user, instance, type START/END, source, date, time, location, site, daySummaryId) └── PairedEntry (START paired with its END) TimeTrackingDaySummary (user, date, totals, incidences, auto-close state, schedule snapshot) ├── TimeTrackingDaySummaryHoliday └── TimeTrackingDaySummaryTimeOff WorkSchedule (fixed schedule) → ScheduledDays (per-weekday time slots) → WorkScheduleUsers (user assignments) Shift (name, color, timeSlots; flavor depends on caller — see §3 "Shift management") → ShiftTimeSlot ShiftAssignedDay (day + user + region) → ShiftAssignedShift → ShiftAssignedTimeSlot ShiftRotation → ShiftRotationDay → ShiftRotationDayShift (shiftCode → Shift row with isTemplate=false, owned by this rotation) TimeTrackingKiosk (instance device) ↔ TimeTrackingKioskLink (QR pairing flow) ↔ TimeTrackingKioskUser (user enabled on kiosk) TimeTrackingFaceAssignment (user face enrolled for face-clock) HourCategory (overtime definition) → HourCategorizationRule (DSL predicate over worked/scheduled/day-type fields) └── TimeTrackingCategorizedHour (materialized calculation for a user+day+category) └── TimeTrackingCategorizedHourApprovalStep (approval workflow per categorized hour) └── TimeTrackingCategorizedHourApprovalStepPotentialApprover TimeTrackingSetting (per-instance) / TimeTrackingUserSetting (per-user) ``` ### Key enums / types | Concept | Values | |---------|--------| | Entry type (`TimeTrackingEntryType`) | `START`, `END` | | Entry source (`TimeTrackingEntrySource`) | user-generated, auto-close, day-change, kiosk, public API, bot app | | Employee status (`TimeTrackingEmployeeStatus`) | clocked-in / clocked-out / on leave / absent, etc. | | Incidence | `LATE`, `ABSENT`, `UNDERWORKED`, location incidences | | Auto-close mode (`TimeTrackingPolicyAutoCloseMode`) | off / fixed-hours / schedule-based | | Categorized hour status | `AUTO_APPROVED`, `PENDING`, `APPROVED`, `REJECTED`, `IN_PROGRESS` (filter-only), `CANCELLED_BY_MODIFICATION` | | Approval step state | `PENDING`, `WAITING_OTHER_STEP`, `APPROVED`, `REJECTED` | | Night-shift assignment | start-of-shift vs end-of-shift (affects day attribution) | | Schedule source (`DaySummaryScheduleSource`) | from `WorkSchedule` vs from `Shift` assignment | | Kiosk link status | pending / linked / expired | | Face assignment source | kiosk enrollment, backoffice upload | --- ## 3. Critical Business Flows ### Entry creation (`createEntry` / `createEntryTransactional`) 1. Resolve entry type — enforces "no two same-type in a row" (`DUPLICATE_TYPE_ERROR`). 2. Resolve day attribution — night-shift policies attribute entry to start-of-shift or end-of-shift day (`NightShiftAssignment`). 3. Get or create `TimeTrackingDaySummary` for resolved date. 4. Persist entry, update day summary aggregates (worked hours, incidences). 5. If `hasTimeTrackingDesertEagleEnabledFF` → mirror entry to Desert Eagle via `createDesertEagleEntry` (external time-tracking sync service). 6. Recompute hour categorization asynchronously (enqueue `TIME_TRACKING_CALCULATE_CATEGORIZED_HOURS`). ### Entry update / offline sync - `updateEntries(summaryId, updates)` — admin/manager edits; recomputes day summary and recategorizes hours. - `syncEntries(entries, userId)` — bot-app offline sync. Entries must be **sorted newest first**; service applies them in order, reconciling auto-close state. ### Auto-close Two modes in policy: - **Fixed hours** — auto-close N hours after last START if no END. - **Schedule-based** — auto-close at schedule end with tolerance. `closeEntriesForDate(from, to, limit)` scans open entries; creates END entries with source `AUTO_CLOSED`. `AutoCloseData`/`AutoCloseFixedHoursData` carry parameters. ### Face clock (`faceClock`) Used from kiosks with facial recognition. Flow: 1. Kiosk bot-app authenticates. 2. `FacialRecognitionKioskPort` performs face search against indexed `TimeTrackingFaceAssignment`. 3. On match → call `createEntry` with source `KIOSK_FACE`. ### Categorized hours — lifecycle Categorized hours materialize how many hours of a given category (e.g. 50% overtime) a user worked on a day. They're computed deterministically from entries + schedule + rules, then optionally go through an approval workflow. 1. **Trigger** — any change that affects a user's worked/scheduled hours for a day (entry create/update, day summary, policy assignment, policy hour-categories change, user schedule change) enqueues `TIME_TRACKING_CALCULATE_CATEGORIZED_HOURS` to SQS. 2. **Compute** — `TimeTrackingQueueConsumer` dispatches to `TimeTrackingQueueConsumerPort.handleCalculateCategorizedHours`. Evaluates rules in `HourCategorizationRule` against the day's context → persists/updates `TimeTrackingCategorizedHour`. 3. **Status** — no approval policy attached → `AUTO_APPROVED`. With approval policy → `PENDING` + materialized approval steps. 4. **Review** — `reviewCategorizedHourApprovalStep(approvalStepId, state)` progresses steps; last step APPROVED → `TimeTrackingCategorizedHour.status = APPROVED`. 5. **Edit status** — admin force via `editCategorizedHourStatus`. ### Categorization explain (recompute on demand) `TimeTrackingHourCategorizationService.explainCategorization(instance, userId, dateString, requesterId, hasManageCapability)` runs the categorization engine in memory for a single (user, date) and returns a `CategorizationExplanation`: a tree (`RunNode`) showing how every category in the user's active policy was evaluated, plus the currently stored `categorizedHours` for that day. Read-only and idempotent: no trace persistence, no recomputed values written. The endpoint backs `GET /time-tracking/categorized-hours/explain?userId=X&dateString=Y`. Mechanics: - The engine evaluates each rule via `buildRuleAst` → `executeRuleAst` (`business/utils/`), producing a **literal-order linear-chain AST** (`business/models/timeTracking/ruleAst.ts`). The explain builds the tree **directly** — `RUN → CATEGORY → RULE`, each `RULE` carrying its rule's executed, annotated chain (`BASE → FILTER`(`INTERSECT`/`DIFFERENCE`/`GATE`)/`THRESHOLD_GT`/`THRESHOLD_LT`, one node per condition, in array order). There is **no** `TraceEvent` stream, no emitters, no `buildAST` reconstruction (all removed in SQEG-2732). - Normal (hot-path) runs log the same executed AST — one line per `(user, date)` tagged `HOUR_CATEGORIZATION_TRACE_MESSAGE`, correlated by a per-batch `traceRunId`. Both paths build their per-day nodes via the shared `buildDayCategoryNodes`, so the explain endpoint and the prod log can't drift. `executeRuleAst(…, annotate)` gates the per-node annotation: off for pure calc (`calculateCategoryHours`), on for explain + log. - The recomputed AST may diverge from `categorizedHours[]` if entries were edited or an admin overrode a status. The response carries both so the UI can flag the delta. The tree never reflects admin overrides — those live on `categorizedHours[].statusOverriddenByAdminId`. - Auth: `manage OR self`. Without `MANAGE_TIME_TRACKING`, a user can only request their own `userId`; service throws `ForbiddenError` otherwise. - Side effect: `getOrCreateDaySummariesByUser` may insert an empty day summary row on first call for a date with no entries — same as `getCategorizedHour`. ### Bulk review of categorized hours — lifecycle Bulk-reviews many hours' current-step in one atomic transaction from the App PENDING approvals tab. Only hours where the caller is a potential approver on the active step are reviewed; everything else lands in `skippedIds` (backend-enforced regardless of UI). Optimistic concurrency via `WHERE state = :pendingState` guard means a concurrent single-hour reviewer wins races silently. Notifications are deferred via `TIME_TRACKING_NOTIFY_BULK_CATEGORIZED_HOUR_REVIEWED` and reuse the single-path `notifyCategorizedHourStepReviewed`. See `docs/superpowers/specs/2026-04-24-bulk-review-categorized-hour-steps-design.md`. ### Policy assignment changes (`handlePolicyAsignationChanges`) When users are (un)assigned from a policy on a given date, `TimeTrackingCategorizedHourService`: - Deletes categorized hours without approval from the old policy for unassigned users. - Creates categorized hours without approval for newly assigned users, going back to `date`. - Creates categorized hours with approval for the current date onward (via `createCategorizedHoursWithApprovalForAssignedUsers`). All in a single transaction on the categorized-hour repository. **Keeping approved/pending rows is deliberate** — don't blanket-delete. ### Shift management - **`Shifts` is polymorphic.** Three flavors share the same table; the discriminator is the caller / surrounding relations, not a column: - **Templates** (`isTemplate=true`) — reusable definitions surfaced in "Repositorio de turnos". Created via `POST /shifts/templates`. - **Ad-hoc one-off cell shifts** (`isTemplate=false`, no rotation owner) — created at calendar-cell assignment time when the user doesn't save to the repository. Referenced by `ShiftAssignedShifts.shiftCode + shiftVersion`. - **Rotation-owned shifts** (`isTemplate=false`, owned by a `ShiftRotation`) — see "Shift rotations" below. Ownership is recoverable through `ShiftRotationDayShifts.shiftCode` (rotation-owned) or `ShiftAssignedShifts.shiftCode` (one-off); the `Shifts` table itself does not encode it. Repository UI queries always filter `isTemplate=true`, so the other flavors stay hidden from that surface. - **Calendar assignment** — `ShiftAssignedDay` (per user+date+region) → `ShiftAssignedShift` → `ShiftAssignedTimeSlot`. - **Bulk ops** — `assignBulk`, `bulkDelete`, `copyAndPaste`, `duplicatePeriod` all mutate the assigned-shifts grid. - **Move (drag-and-drop)** — `move` re-parents one or more `ShiftAssignedShift` rows to new `(targetUserId, targetDate)` cells in a single advisory-locked transaction. Target cells may already contain other shifts as long as the source shift's time slots do not overlap with theirs (REST shifts always conflict with anything in the cell). Vertical moves allowed (unlike copy-and-paste); multiple source shifts may share a single target cell (e.g. moving every shift out of a multi-shift cell). Atomic; emits `SCHEDULE_CHANGE` for source + target user-date ranges. - Assigned shifts feed into day summary as `DaySummaryScheduleSource.SHIFT` (vs `WORK_SCHEDULE`). ### Shift rotations A `ShiftRotation` owns its shifts **inline** at the domain level (no reference to Shift Templates). See `docs/adr/0001-rotation-shifts-reuse-shifts-table.md` for the storage decision. - **Storage.** Each rotation-day shift definition is a row in the existing `Shifts` table with `isTemplate=false`, written via `shiftService.createShift(instanceId, false, ...)`. `ShiftRotationDayShifts.shiftCode` is the soft FK; resolution is by `lastVersion=true`. No new tables, no new columns on `Shifts`. - **Identity across edits.** PUT bodies echo `shiftCode` for shifts that already exist in the rotation. Missing `shiftCode` = create (new `Shift` row via `createShift`); present and recognized = update via version-bump; present in DB but missing from body = soft-delete the `Shift` row (`deletedAt = now()`). Codes that don't belong to this rotation (templates, other rotations) are rejected with `BadRequestError(SHIFT_ROTATION_INVALID_SHIFT_CODE)`. - **Version-bump on edit.** Editing the name/color/description/time-slots of an existing rotation-shift goes through `shiftService.bumpShiftVersion` (a sibling of `updateShiftTemplate` that does not require `isTemplate=true`): insert a new row with `code=existing.code, version=existing.version+1, lastVersion=true` + new `ShiftTimeSlot` children; flip the previous row to `lastVersion=false`. Past `ShiftAssignedShifts.shiftCode + shiftVersion` references keep resolving to their stamped version. **Rotation edits are not retroactive today** — future cells stamped from the rotation pick up `lastVersion=true`; existing cells stay frozen via their `shiftVersion + ShiftAssignedTimeSlot` snapshot. - **Rotation delete cascades.** On `ShiftRotations.deletedAt`, soft-delete every rotation-owned `Shift` (all versions of every `shiftCode` reachable through `ShiftRotationDays → ShiftRotationDayShifts`) in the same transaction. The `Shift` rows stay queryable for any past `ShiftAssignedShifts` that referenced them. - **Cross-rotation injection guard.** On update, build the set of `shiftCode`s currently owned by *this* rotation from the loaded graph. Reject body entries whose `shiftCode` is not in that set — defends against a client pasting a template code or another rotation's code. - **Concurrency.** The version-bump path is implemented but **no per-rotation advisory lock is in place**. Two concurrent PUTs on the same rotation can both try to insert `version=N+1` for the same code and race on `Shifts (code, version)` unique index, or interleave `lastVersion` flips. Acceptable today (low concurrent-edit likelihood per rotation); revisit if it becomes a real source of conflict. Precedent for adding one: `shiftAssignment.ts` move/copy ops. - **Rotation assignment (SQCC-30).** `POST /shift-rotations/:id/assign` stamps the rotation onto users' calendars: fire-and-forget, **one cycle** from `startDate` (day position 1 → startDate, consecutive), **no assignment entity** persisted, **recurrence out of scope**. Orchestrated by `RotationAssignmentService` → `ShiftAssignmentService.assignShiftCells` (reuses the same overlap rule as `assign` — non-overlapping WORK stacks, conflict/REST collision rejects the whole atomic batch). WORK days stamp one `ShiftAssignedShift` per rotation shift; REST days stamp a `type='REST'`, `shiftCode=NULL` row. Every stamped row carries lineage. Past `startDate` allowed (mirrors `assign`). - **Calendar provenance.** `ShiftAssignedShifts.shiftRotationId` + `shiftRotationDayPosition` carry the rotation lineage per stamped shift (columns added by SQCC-11; **mapped to the DAO/model and populated by the rotation-assign path in SQCC-30** — `assign`/paste/move leave them `null`). These two columns are **internal lineage only** — they are **not** exposed on the calendar wire. Instead, the calendar surfaces a single derived boolean **`isFromShiftRotation`** (`= shiftRotationId != null`) on `ShiftWithAssignedData.shifts[]` (manager calendar — `mapShiftsWithAssignedData`) and on `ShiftAssociation` (my-calendar — `mapShiftAssociations`), so the client can render the "from rotation" icon without coupling to the provisional lineage columns (SQCC-31). The public-API calendar (`PublicApiShiftInfo`) carries neither the lineage nor the boolean. REST cells are flagged too: rotation REST cells appear in the manager calendar's `shifts[]` (as `type='REST'`, `isFromShiftRotation=true`), but not in the my-calendar `shiftAssociations` (which drops entries with no time slots). - **For the recurrence ticket (lineage is provisional).** `shiftRotationDayPosition` is a stopgap for the anchor we deliberately don't persist today (no assignment entity). Once recurrence introduces a config table, the clean shape is a single `recurrenceId` FK on the stamped shift → the config row owns `rotationId` + anchor start date + cycle length, making **both** `shiftRotationId` (= `config.rotationId`) and `shiftRotationDayPosition` (= `(daysBetween(anchor, cellDate) mod cycleLength) + 1`) **derivable, hence droppable** (keep only as read-denormalization to avoid the join). Modeling no-recurrence as a single-cycle config (e.g. REPEAT=0) unifies all assignments under that table and **reintroduces a first-class assignment entity** (assignments become editable/queryable objects; the assign endpoint would likely return an id instead of `204`). This is **cheap to adopt while unshipped** — there's no installed data and no external API consumers. The *only* migration cost is conditional: if SQCC-30 reaches production before recurrence is built, the accumulated fire-and-forget cells need backfilling into config rows — and even then it's lossless, because `anchor = cellDate − (shiftRotationDayPosition − 1) days` reconstructs each cell's anchor (grouping contiguous same-rotation cells would otherwise be a guess). So: keep populating `shiftRotationDayPosition` as cheap insurance for that *if*, but don't treat the fire-and-forget → config switch as load-bearing — it's a design choice to make when recurrence requirements are clear, not an irreversible commitment. - **Calendar filter by rotation (SQCC-25).** `GET /shifts/calendar` accepts an optional `shiftRotationIds?: string[]` (UUIDs; client sources them from `GET /shift-rotations`, not from calendar cells). It **narrows the user axis only** (mirrors `searchType=BY_SHIFT`): a user stays on the page iff they have ≥1 `ShiftAssignedShift` with `shiftRotationId IN (...)` in `[startDate, endDate]` — **REST cells count** (lineage-only match, no `type` predicate). Matched users' `days[].shifts[]` are **not** filtered (other shifts, including other rotations', still render — the client distinguishes via SQCC-31's `isFromShiftRotation`). Wired in `ShiftCalendarService.getUserIdsAndTotals` as an independent AND step (like `siteId`), so subordinate/site/segmentation/search scoping and pagination are preserved. The "which users have rotation(s) X in range" question is owned by `RotationOccurrenceService` (single front door, today delegates to `ShiftAssignedDayRepositoryPort.getUserIdsWithRotationInRange`) — see CONTEXT.md *Rotation Occurrence*. **Future-source seam:** when recurrence lands, the config-derived (non-materialized) source unions in *inside that one service method*; callers don't change. - **UUID generation.** Rotation graph ids (`ShiftRotation.id`, etc.) use `uuidv7()`; rotation-owned `Shift.code` values come from `shiftService.createShift`, which currently generates `uuidv4()`. Pre-existing inconsistency; tracked for a follow-up cleanup. ### Kiosk link flow 1. Admin creates `TimeTrackingKiosk` device in backoffice. 2. `PUT /kiosk/:id/link-code` generates a short code (`TimeTrackingKioskLink`). 3. Physical kiosk (bot-app) calls `validateCode` → binds device to kiosk (botAppId). 4. Kiosk uses bot-app JWT for `faceClock` and entry endpoints under `/time-tracking-kiosk`. ### Public API entries - `POST /public-api/time-tracking/entries/clockIn` + `/clockOut` — API key auth. - `deleteEntryForPublicApi` — deletes an entry without updating the day summary (caller recomputes). Syncs deletion to Desert Eagle if FF on. ### Reports - `GET /day-summaries` — paginated sync response. - `GET /day-summaries` (listing) + `/day-summaries/detail` always carry a per-target `grants` array (listing per-item via `EmployeeTableSC` extraData, detail top-level). FF `TIME_TRACKING_CERBERUS_ENABLED` gates only the computation — off ⇒ `[]`. Logic in `presentation/helpers/timeTrackingPermissions.ts`. **Gotcha:** actor must be `res.locals.loggedUser`, not a `userDAOtoUser`-mapped user (the mapper drops `specificPermissions`). - `GET /report-async` — enqueues report job; emails result via `ReportService` + S3 signed URL. - `GET /report` is **DEPRECATED** (still in app router); prefer `report-async`. --- ## 4. Dangerous Zones 1. **`timeTracking.ts` ~2714 lines — God service.** Entries, day summaries, incidences, auto-close, face clock, notification schedules, public-API deletion all live here. Search the exact method before touching. Many similarly named helpers for paired entries, schedule resolution, incidence detection. 2. **Duplicate-entry invariant** — `createEntry` enforces no two consecutive same-type entries per user. Offline `syncEntries` depends on **newest-first ordering**; violating this silently double-clocks or skips entries. 3. **Day attribution for night shifts** — `NightShiftAssignment` toggles whether an entry's date is start-of-shift or end-of-shift day. Mutating this setting emits `TIME_TRACKING_SETTING_UPDATED` and re-propagates through `TimeTrackingPoliciesAdapter.handleSettingsUpdated` → policy → event → affected users. Changing logic here can shift every historical summary. 4. **Desert Eagle dual-write** — when `TIME_TRACKING_DESERT_EAGLE_ENABLED` FF is on, entry create/update/delete mirrors to Desert Eagle. If the FF flips mid-flow or the call fails, systems drift. Check FF at the *start* of each flow, not reactively. 5. **Categorized-hours recompute is async** — enqueued to SQS (`TIME_TRACKING_CALCULATE_CATEGORIZED_HOURS`). A failure there won't surface in the write path; the DLQ is the only signal. Don't assume categorized hours are up to date immediately after an entry write. 6. **Two approval paths** — `TimeTrackingPolicyApprovalStep` (policy/request-level) vs `TimeTrackingCategorizedHourApprovalStep` (per categorized-hour). Separate DAOs, separate services (`policies.ts` vs `timeTrackingCategorizedHourApprovalService.ts`). Never mix them. 7. **`handlePolicyAsignationChanges`** — deliberately preserves approved/pending categorized hours and only touches `AUTO_APPROVED` ones. A naïve "delete all categorized hours for removed users" wipes approved overtime records. 8. **Policy hour-category rules** — rule DSL (`isValidHourCategorizationRule` VC) is expression-like over fields in `hourCategorizationFields` with `hourCategorizationOperators`. Invalid rule saved → recompute throws silently on the consumer and the row ends up in DLQ. Always validate via the VC. 9. **Holidays + time-off merge into day summary** — `TimeTrackingDaySummaryHoliday` and `TimeTrackingDaySummaryTimeOff` are denormalized. Time-off approve/cancel, region holiday create/delete, and region user assignment/unassignment in `timeTracking.ts` mutate day summaries AND now enqueue `SCHEDULE_CHANGE` so categorized hours recompute (`handleScheduleChanges` in `TimeTrackingCategorizedHourService`). When adding new code paths that mutate day summaries based on time-off, holidays, or region membership, dispatch `SCHEDULE_CHANGE` after persistence using `scheduleCategorizedHoursPort.schedule({ type: CategorizedHoursActionType.SCHEDULE_CHANGE, payload: { instanceId, userIds, startDate, endDate } })`. The `module-timeOff` dependency is real (see `project.json`). 10. **Mapper pitfalls** — missing Sequelize `include` → nested mapper returns `undefined` silently (e.g. `timeTrackingPolicyMapper` for approval steps + responsibles). Verify includes before mapping. 11. **Cascade deletes are manual** — deleting a policy, kiosk, or work schedule cascades through services, not DB constraints. Missing a step → orphan `TimeTrackingPolicyHourCategory` / `WorkScheduleUsers` / categorized hours. 12. **Grid queries (`queryBuilders/grid.ts`, `shiftRelation.ts`)** — hand-rolled SQL for the shift-management grid. High-volume, indexed on `(instanceId, date, userId)`. Avoid adding ORM-level joins that kill these query plans. 13. **i18n** — `presentation/i18n/*.json` is the source for translation keys referenced by the module (notifications, reports). Edit Spanish (`es.json`) and use the `i18n-sync-translations` skill to propagate. 14. **Public API entry deletion** — `deleteEntryForPublicApi` does *not* update the day summary. Caller (external client) is expected to recompute. Do not add recomputation inside this method without coordinating with Public API consumers. 15. **Don't add to `timeTracking.ts`.** Check §8 first. Prefer a focused service (`TimeTrackingCategorizedHourService`, `TimeTrackingFaceAssignmentService`, etc.) or create a new one. Only add here when logic crosses entries + summaries + schedules + policy. 16. **`Shifts` table is polymorphic — three flavors share it** (templates, ad-hoc one-off cells, rotation-owned). There is no discriminator column; ownership is recoverable only via the surrounding relations (`ShiftRotationDayShifts.shiftCode` for rotation-owned, `ShiftAssignedShifts.shiftCode` for one-off, `isTemplate=true` for templates). Consequences: (a) **never broaden queries that read all `Shifts` without thinking about which flavor you mean** — a "list shifts for this instance" without `isTemplate=true` will leak rotation-private shifts into the repository UI; (b) **never hard-delete a `Shift` row** — always soft-delete via `deletedAt` so any `ShiftAssignedShifts.shiftCode + shiftVersion` that already references it keeps resolving; (c) **lifecycle is enforced in services, not the DB** — deleting a rotation must cascade-soft-delete its owned `Shifts` and re-link operations must preserve `lastVersion` semantics. See `docs/adr/0001-rotation-shifts-reuse-shifts-table.md`. --- ## 5. Architecture & DI All registrations live in `portsDI.ts` → exported as `trackingPortsHandlers`. Business services extend `BasePort` and are bound to an adapter class (`*ServiceAdapter`); repository ports bind to repository adapters. ### Business-level ports (abstract service contracts) | Port | Adapter | Responsibility | |------|---------|---------------| | `TimeTrackingService` | `TimeTrackingServiceAdapter` | Entries, day summaries, incidences, auto-close, notification schedules, face clock | | `WorkScheduleServicePort` | `WorkScheduleServiceAdapter` | Work schedules CRUD + user assignment | | `TimeTrackingPoliciesPort` | `TimeTrackingPoliciesAdapter` | Policies, approval-step config, user assignment, event fan-out | | `TimeTrackingKioskPort` | `TimeTrackingKioskAdapter` | Kiosk device CRUD, kiosk-user management | | `TimeTrackingKioskLinkPort` | `TimeTrackingKioskLinkAdapter` | QR link-code flow | | `TimeTrackingNotificationsPort` | `TimeTrackingNotificationsAdapter` | Push, email, notification center | | `TimeTrackingEmployeePort` | `TimeTrackingEmployeeAdapter` | Employee table enrichment for TT views | | `UsersServicePort` | `UsersServiceAdapter` | User lookups scoped to TT | | `ReportServicePort` | `ReportService` | Sync + async day-summary reports | | `ScheduleCategorizedHoursPort` | `ScheduleCategorizedHoursAdapter` | Schedule/recompute categorized hours | | `TimeTrackingQueueConsumerPort` | `TimeTrackingQueueConsumerAdapter` | SQS handlers dispatch target | ### Repository ports (infrastructure) `TimeTrackingRepositoryPort`, `WorkScheduleRepositoryPort`, `TimeTrackingPoliciesRepositoryPort`, `TimeTrackingPolicyApprovalStepsRepositoryPort`, `TimeTrackingKioskRepositoryPort`, `TimeTrackingKioskLinkRepositoryPort`, `TimeTrackingSettingRepositoryPort`, `TimeTrackingUserSettingRepositoryPort`, `TimeTrackingConfigRepositoryPort`, `TimeTrackingEntriesRepositoryPort`, `TimeTrackingFaceAssignmentRepositoryPort`, `TimeTrackingHourCategorizationRepositoryPort`, `TimeTrackingKioskUserRepositoryPort`, `TimeTrackingCategorizedHourRepositoryPort`, `TimeTrackingCategorizedHourApprovalStepRepositoryPort`, `TimeTrackingIncidenceNotificationLogRepositoryPort`, `ShiftRepositoryPort`, `ShiftAssignmentRepositoryPort`, `ShiftAssignedDayRepositoryPort`, `ShiftRotationRepositoryPort`. ### Cross-module ports registered here - `UsersTimeTrackingSettingPort` → `UsersTimeTrackingSettingAdapter` (consumed by `module-users` to expose TT settings on user payloads). - `FacialRecognitionKioskPort` / `FacialRecognitionTimeTrackingConfigPort` → adapters that let `module-facialRecognition` drive the TT-specific config. - `TimeTrackingCDCPort` → `TimeTrackingCDCAdapter` (consumed by `module-changeDataCapture`). - `TimeTrackingDaySummaryPort` → `TimeTrackingDaySummaryAdapter` (consumed by `module-timeOff` to fetch day summaries without importing from timeTracking). - `TimeTrackingWorkScheduleDataPort` → `TimeTrackingWorkScheduleDataAdapter` (consumed by `module-timeOff` to fetch work-schedule data without importing from timeTracking). ### Implicit module dependencies (`project.json`) `attachments, audit, auth, botApps, changeDataCapture, deferredExecution, departments, facialRecognition, featureFlags, instances, jobs, organizationCharts, permissions, profileFields, regions, segmentations, timeOff, users, monolith-shared`. ### DAOs → tables All DAOs under `infrastructure/dataAccessObjects/*`. Registered in `dataAccessObjects/all.ts` (`timeTrackingDataAccessObjects`). Table names match DAO names (`TimeTracking*`, `Shift*`, `WorkSchedule*`, `TimeSlot*`, `ScheduledDays`) — **no legacy renames** (unlike TimeOff). ### Queue - **SQS**: `TIME_TRACKING_CATEGORIZED_HOURS` — consumed by `TimeTrackingQueueConsumer`. Message types in `timeTrackingQueueMessageTypes.ts`: - `TIME_TRACKING_CALCULATE_CATEGORIZED_HOURS` → `handleCalculateCategorizedHours`. - `TIME_TRACKING_NOTIFY_BULK_CATEGORIZED_HOUR_REVIEWED` → `handleNotifyBulkCategorizedHourReviewed` (deferred fan-out for bulk reviews; consumer re-runs single-path `notifyCategorizedHourStepReviewed` per (hourId, reviewedStepId) pair). - Unknown messages → deleted (not retried). - DLQ wired through `BaseQueueConsumer`. ### CDC (Kafka) `TimeTrackingCDCService` (extends `BaseCDCService`) — subscribes to `kafkaUsersTopicName`: - User soft-delete → cascades kiosk user records, face assignments, TT user settings. - DLT: `${kafkaTimeTrackingGroupId}.dlt`. ### Events (internal EventEmitter) Emitted by `TimeTrackingPoliciesAdapter`: - `TIME_TRACKING_POLICY_ASSIGNED` — users added to policy. - `TIME_TRACKING_POLICY_UNASSIGNED` — users removed. - `TIME_TRACKING_POLICY_UPDATED` — policy config changed. Listened by: - `TIME_TRACKING_SETTING_UPDATED` — `handleSettingsUpdated` re-propagates night-shift changes to all policies. --- ## 6. HTTP Surface Five router files in `presentation/routers/`. Each exports one or more `start{Context}Router` functions wired from `src/api/routes/*root.ts`. | Router file | Mount(s) | Auth | Exports | |-------------|----------|------|---------| | `app.ts` | `/time-tracking`, `/shifts`, `/work-schedules` (all under App root) | JWT + app | `startTimeTrackingAppRouter`, `startShiftsAppRouter`, `startWorkScheduleAppRouter`, `startTimeTrackingPoliciesAppRouter` | | `admin.ts` | `/time-tracking`, `/work-schedules` (Backoffice root) | JWT + backoffice | `startTimeTrackingBackofficeRouter`, `startWorkScheduleBackofficeRouter` | | `api.ts` | `/time-tracking` (Public API root) | API key + rate limit | `startTimeTrackingPublicApiRouter`, `startShiftManagementPublicApiRouter` | | `botApp.ts` | `/time-tracking-kiosk` (Bot-app root) | Bot-app JWT (+ public variant) | `startTimeTrackingKioskBotAppsRouter`, `startTimeTrackingKioskPublicBotAppsRouter` | | `devops.ts` | `/time-tracking`, `/work-schedules` (Devops root) | JWT (no scope) | `startTimeTrackingDevopsRouter`, `startWorkScheduleDevopsRouter` | `admin.ts` is split into sub-functions: `startTimeTrackingPoliciesBORouter`, `startTimeTrackingKioskBORouter`, `startTimeTrackingSettingsBORouter`, `startTimeTrackingUserSettingsBORouter`, `startTimeTrackingHourCategorizationBORouter`. **Permissions are checked in controllers**, not routers, except for `CapabilityName.VIEW_TIME_TRACKING` used as a bot-app capability guard in `app.ts`. ### Notable endpoints | Context | Method | Path | Purpose | |---------|--------|------|---------| | App | POST | `/time-tracking/entries` | Create clock-in/out | | App | POST | `/time-tracking/entries/sync` | Offline bulk sync (newest-first) | | App | GET | `/time-tracking/day-summary` | Recent summary (self) | | App | GET | `/time-tracking/day-summaries` | Paginated summaries with filters | | App | GET | `/time-tracking/report-async` | Async report to email | | App | GET | `/time-tracking/report` | **DEPRECATED** | | App | GET | `/time-tracking/notification-schedules` | Computed reminders | | App | GET/PATCH | `/time-tracking/categorized-hours/...` | List + update categorized hours + approval review | | App | POST | `/shifts/move` | Drag-and-drop: re-parent shifts to empty cells | | App | POST | `/shift-rotations` | Create a shift rotation (full graph) | | App | GET | `/shift-rotations` | List paginated, name search, sort name ASC | | App | GET | `/shift-rotations/:id` | Get rotation with all days + shifts | | App | PUT | `/shift-rotations/:id` | Replace rotation (atomic full-graph) | | App | DELETE | `/shift-rotations/:id` | Soft-delete; lineage on ShiftAssignedShifts preserved | | App | POST | `/shift-rotations/:id/assign` | Stamp one rotation cycle onto users' calendars from a start date (fire-and-forget; no recurrence; lineage stamped per cell). Body `{ userIds[], startDate }`. Returns 204. Auth: view-shifts + subordinate scoping (mirrors `/shifts/assign`). | | App | GET | `/time-tracking/categorized-hours/explain?userId=&dateString=` | Recompute on demand and return AST of how every category evaluated; returns recomputed values + stored `categorizedHours` for that day. Manage-or-self auth. | | App | POST | `/time-tracking/categorized-hours/bulk-review` | Bulk approve/reject active steps across many categorized hours | | App | GET | `/time-tracking/categorized-hours/category-counts` | Counts of categorized hours grouped by hour category under the list filters (powers frontend "select all" totals; reuses `buildCategorizedHoursQuery` with `GROUP BY hourCategoryId, name` and `include.required=true`). | | Backoffice | `/time-tracking/policies/...` | Full policy CRUD + assignUsers + approval-users | | Backoffice | `/time-tracking/hour-categories/...` | Hour-category + rule CRUD | | Backoffice | `/time-tracking/kiosk/...` | Kiosk CRUD + link/unlink | | Backoffice | `/time-tracking/user-settings/bulk-upsert` | Bulk kiosk PIN assignment | | Public API | POST | `/time-tracking/entries/clockIn` / `clockOut` | External clock-in/out | | Bot-app | `/time-tracking-kiosk/...` | Face search, kiosk entry creation, kiosk users listing | | Devops | POST | `/time-tracking/categorized-hours/recalculate` | Recompute `hours` value in place for an existing categorized-hour row (body: `{ instanceId, categorizedHourId }`). 400 if value unchanged, 404 if missing. Escape hatch; Datadog-tagged log. | --- ## 7. Testing ### Unit (`test/modules/timeTracking/`) Split into `adapters/`, `business/services/`, `infrastructure/`, `presentation/`, `utils/`. Uses Jest, `jest-mock-extended`, `@faker-js/faker`. ### Integration (`test-integration/api/timeTracking/`) Top-level test files: ``` approvalUsers.test.ts daySummaryFilter.test.ts faceRecognition.test.ts autoClose.test.ts daySummaryIncidenceStatus.test.ts kiosk.test.ts daySummaryDetail.test.ts entries.test.ts lateIncidence*.test.ts (×4) notificationSchedules.test.ts policies.test.ts publicApi.test.ts pushNotificationSchedules.test.ts settings.test.ts timeTracking.test.ts timeTrackingDebeziumConsumerService.test.ts userSettings.test.ts workSchedule.test.ts ``` Sub-folders for heavier areas: `shifts/` (assignment, calendar, copyAndPaste, duplicatePeriod, publicApi, regions, shifts, templates), `hourCategorization/`, `kioskUser/`, `faceAssignment/`. Setup via `CreateCommunity.new()` + commands under `test-integration/commands/timeTracking/` and `commands/shifts/`. Isolation by instance — follow the monolith AGENTS.md isolation rules strictly; the module's high parallel write rate makes flaky-test traps common (don't assume any empty table). --- ## 8. Services All services live under `business/services/`. God service at top; focused services under sub-folders. ### Entries / summaries / kiosk / policies (top level) | Service | Responsibility | Size | |---------|---------------|------| | `TimeTrackingService` (`timeTracking.ts`) | **God service** — entries, day summaries, incidences, auto-close, face clock, notification schedules, public-API entries | ~2714 lines | | `ReportService` (`report.ts`) | Sync + async day-summary reports, Excel/CSV generation | ~995 | | `TimeTrackingHourCategorizationService` (`hourCategorizationService.ts`) | Hour category CRUD, rule validation, policy-hour-category associations | ~956 | | `WorkScheduleServiceAdapter` (`workScheduleAdapter.ts`) | Work schedule CRUD, user assignment, schedule resolution | ~633 | | `TimeTrackingCDCService` (`timeTrackingCDCService.ts`) | Kafka users-topic consumer; cascades user deletions through TT | ~516 | | `TimeTrackingPoliciesAdapter` (`policies.ts`) | Policy CRUD, approval-step config, settings event fan-out | ~480 | | `TimeTrackingEmployeeAdapter` (`employeeTableAdapter.ts`) | Employee table enrichment | ~405 | | `TimeTrackingKioskAdapter` (`kiosk.ts`) | Kiosk device CRUD + kiosk-user mgmt | ~209 | | `TimeTrackingKioskLinkAdapter` (`kioskLink.ts`) | Link-code lifecycle (generate/validate/bind) | ~171 | | `DaySummaryDetailService` (`daySummaryDetailService.ts`) | Detail view composition for a day summary | ~128 | | `PublicTimeTrackingService` (`publicTimeTracking.ts`) | Public-API facade: clockIn/clockOut delegation | ~112 | | `TimeTrackingQueueConsumer` (`timeTrackingQueueConsumer.ts`) | SQS consumer wiring for categorized-hours calc | ~102 | | `UsersServiceAdapter` (`usersServiceAdapter.ts`) | TT-scoped users lookups | ~75 | | `TimeTrackingSettingService` (`setting.ts`) | Instance settings; emits `TIME_TRACKING_SETTING_UPDATED` | ~55 | | `TimeTrackingNotificationService` (`timeTrackingNotificationService.ts`) | Thin notification orchestration | ~48 | ### Time tracking (sub-folder `timeTracking/`) | Service | Responsibility | |---------|---------------| | `TimeTrackingCategorizedHourService` | Categorized-hour lifecycle; handles policy assignment + policy-hour-category changes; creates/deletes records respecting approval state | | `TimeTrackingCategorizedHourApprovalService` | Approval-step workflow for categorized hours (PENDING → APPROVED/REJECTED) | | `TimeTrackingFaceAssignmentService` | Face enrollment + search integration | | `TimeTrackingUserSettingService` | Per-user settings (PIN, kiosk permissions, etc.) | | `TimeTrackingUserSettingForUserCreation` | Defaults on user create | | `TimeTrackingKioskUserService` | Kiosk-user membership (which users can clock on which kiosk) | | `TimeTrackingForShift` | Shift-aware time-tracking helpers | | `TimeTrackingIncidenceNotificationService` | Post-tx orchestrator for incidence notifications. **LATE / AUTO_CLOSE / ABSENT / NOT_CLOCKED_IN**: day-level idempotency (`entryId IS NULL` in `TimeTrackingIncidenceNotificationLogs`). **LOCATION**: entry-level idempotency (`entryId = triggering entry id`). Admin edits (`updateEntries`) intentionally do NOT dispatch — no retroactive notifications. Hooks: `createEntry`, `syncEntries`, `clockIn`, `clockOut`, kiosk `faceClock` (HTTP + gRPC), gRPC `createTimeTrackingEntry` (Desert Eagle inbound), and `processAutoCloseEntries` (post-commit, per-instance) — the latter calls `evaluateAutoCloseEntries` for AUTO_CLOSE-sourced END entries. Same-day guard divergence: LATE/LOCATION/ABSENT/NOT_CLOCKED_IN apply a strict today-only filter; **AUTO_CLOSE allows today OR yesterday** in instance-local time because the auto-close cron commonly runs in the next-day midnight-buffer window (entries older than yesterday are dropped to bound backfill spam). | ### Shift management (sub-folder `shiftManagement/`) | Service | Responsibility | |---------|---------------| | `ShiftService` | Shift template CRUD | | `ShiftAssignmentService` | Assignment grid, bulk assign/delete, copy-and-paste, duplicate-period, move (~1348 LoC). Exposes the **source-agnostic stamping primitive** `assignShiftCells(instance, user, ShiftCell[], hasManage)` (auth + overlap-validate + persist); `assign` and the rotation path share its persist tail (`persistShiftCells`). Knows nothing about rotations — lineage rides on the generic `ShiftAssignedShift` columns. | | `RotationAssignmentService` | Orchestrates "assign a rotation" (`POST /shift-rotations/:id/assign`). Loads the rotation graph via `ShiftRotationService.getById`, `expand`s it into `ShiftCell[]` (day position → consecutive dates from start, lineage stamped, WORK→one assigned shift per rotation shift, REST→codeless REST row), then delegates to `ShiftAssignmentService.assignShiftCells`. Sits above both peer services; no assignment entity persisted; recurrence out of scope (one cycle). | | `ShiftCalendarService` | Calendar view composition. Filter-by-rotation narrows the user set via `RotationOccurrenceService` (see §3 "Shift rotations"). | | `RotationOccurrenceService` | Single front door for "which users have rotation(s) X in `[range]`" (calendar filter-by-rotation). Today delegates to `ShiftAssignedDayRepositoryPort.getUserIdsWithRotationInRange`; the union point where a future recurrence-derived source plugs in. Knows nothing about the view. | | `ShiftAssignedDayService` | Per-day assignment records + region handling | | `ShiftRotationService` | CRUD of `ShiftRotation`. Atomic full-graph create/update. Each rotation-day shift is materialized as an `isTemplate=false` row in `Shifts` via `shiftService.createShift` (see §3 "Shift rotations"). Updates diff incoming `shiftCode`s against the loaded rotation graph and version-bump existing rows; deletions soft-delete the underlying `Shift`. Rotation soft-delete cascades to all owned `Shifts`. Strict per-day time-overlap validation across each day's shifts. | ### Kiosk (sub-folder `kiosk/`) | Service | Responsibility | |---------|---------------| | `KioskFaceSearchService` | Face-clock search → match → delegation to entry creation | --- ## 9. Adding a New Feature 1. Pick the **sub-domain** (§1) and the **service** that owns it (§8). Never add to `TimeTrackingService` unless the logic genuinely spans entries + summaries + schedules + policy. 2. Define the port method in `business/ports/` or `infrastructure/ports/` (repository). 3. Implement the adapter in `business/services/` (business port) or `infrastructure/adapters/` (repository port). 4. If persistence changes: add/adjust a DAO + mapper, re-export in `infrastructure/dataAccessObjects/all.ts`. 5. Register the binding in `portsDI.ts` → `trackingPortsHandlers`. 6. Add the endpoint in the relevant controller under `presentation/controllers/` and wire it in the matching router (`app.ts` / `admin.ts` / `api.ts` / `botApp.ts` / `devops.ts`). 7. Add VCs/SCs; reuse the existing shared VCs when possible (e.g. `TimeTrackingDaySummaryWithScheduleFiltersVC`). 8. If the change affects worked/scheduled hours: trigger categorized-hours recompute by emitting `TIME_TRACKING_CALCULATE_CATEGORIZED_HOURS` (never call the recompute synchronously from a write path). 9. If the change mutates policy settings that affect categorized hours or day attribution: emit the matching `TIME_TRACKING_POLICY_*` event and confirm `handleSettingsUpdated` handles it. 10. Write unit + integration tests. Integration tests must bootstrap their own community and never assume empty tables (monolith AGENTS.md rules apply). --- ## 10. Keeping This Document Up to Date - **New service created** → add a row to §8 and, if it owns a new sub-domain, update §1. - **New port/adapter** → add to §5 tables. - **New endpoint** → note it in §6 when non-obvious. - **New queue message type** → update §5 Queue section + §3 (if it drives a new flow). - **New danger zone / invariant** → add to §4. - **Existing method renamed/removed** → update or remove the corresponding entry. The document is wrong if it describes code that no longer exists, or omits code that does.