fix(availability-slots): own-employee writes, no upsert by foreign id, atomic merge

Merged
EV
11d ago
+575-42

What was wrong

Availability slots are an employee's bookable hours (Employees → Appointments → Schedules; public booking reads them). The REST routes and the GraphQL fields carry TenantPermissionGuard only — on purpose: the Appointments menu states no permission, so every employee manages their own hours there. What kept hours personal is the employee scope TenantAwareCrudService applies to a caller without CHANGE_SELECTED_EMPLOYEE, and it had gaps. All proven on a real SQLite database through the real controller, resolver, command handlers and CRUD service (availability-slots.authorization.spec.ts):

callerwhat it could do on develop 77606f6199
employeePUT /availability-slots/:id / updateAvailabilitySlot on a colleague's slot id: an upsert on any id of the tenant → the row was rewritten and moved onto the caller's calendar
employeesame through an id in a POST body
employeePOST "for" a colleague: conflicts were searched in the colleague's slots and the caller's new slot was widened over them (leaks their times)
user with no employee record (candidate, viewer…)not scoped at all: read, delete, soft-delete and replace every employee's slots
anyonethe merge deleted the conflicting slots, then saved the new one in a separate statement → a refused save lost the employee's hours (also per bulk entry)

The fix

  • AvailabilitySlotsService.resolveWriteEmployeeId(): a caller with CHANGE_SELECTED_EMPLOYEE files for the employee it names; anyone else for themselves (the rule the recurring-expense handlers already apply; an employee's selector only offers themselves); a caller with no employee record → 403.
  • findConditionsWithoutOwnEmployee() → neverMatchingEmployeeCondition() (the base class documents this override for strictly personal rows).
  • Create handler: owner decided before the conflict lookup; the new row is written from the slot's own members (never the body as a whole — no id, no employee relation); replaceSlots() deletes the owner's conflicts and inserts in one transaction (TypeORM manager, as the other multi-statement writes of core do).
  • updateSlot(): reads the slot through the employee scope first → a slot the caller cannot read answers 404 (as GET /:id does). REST PUT /:id and GraphQL updateAvailabilitySlot both call it.
  • No permission added (would lock employees out of their own Schedules page); SKIP merge mode left as it was (no caller uses it).

Intended behaviour changes: PUT /:id on an id the caller cannot read → 404 instead of creating/rewriting; an id in a POST body is ignored; a candidate/viewer without CHANGE_SELECTED_EMPLOYEE reads no slots and filing one is 403; an employee naming a colleague files the slot as their own (as recurring expenses do).

Evidence

  • Red on develop (fix stashed, same spec): 10 failed / 3 passed — the 3 are controls (own edit, manager merge, employee delete of a colleague's slot, which the scope already refused).
  • Green: 13/13; availability-slots suites 61/61 (resolver spec updated: update goes through updateSlot); tsc -p packages/core/tsconfig.lib.json --noEmit 0 errors; all 35 static gates of static-checks.yml pass; cspell clean on the changed files.

Tracked in the homelab handover HANDOVER-2026-10-15-gauzy-followups.md (Q1).

🤖 Generated with Claude Code


Summary by cubic

Closes the authorization gaps in availability slots so employees can only write their own hours, callers without an employee record can't touch any, and a failed save no longer deletes existing slots.

  • PUT /:id and updateAvailabilitySlot now update only a slot the caller can read (404 otherwise) instead of upserting any tenant id, and write it from the slot's own members so a body employee relation can't override the owner.
  • A create always files a new row for the caller's own employee unless they have CHANGE_SELECTED_EMPLOYEE; only the slot owner's conflicts are merged, and an id in the body is ignored.
  • Callers with no employee record read no slots and get 403 when filing.
  • Conflict removal and the new slot save run in one transaction, so a refused save keeps the employee's existing hours.
  • On update, an explicit null organizationId clears it; omitting it keeps the current value.