fix(availability-slots): own-employee writes, no upsert by foreign id, atomic merge
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):
| caller | what it could do on develop 77606f6199 |
|---|---|
| employee | PUT /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 |
| employee | same through an id in a POST body |
| employee | POST "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 |
| anyone | the 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 withCHANGE_SELECTED_EMPLOYEEfiles 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, noemployeerelation);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 (asGET /:iddoes). RESTPUT /:idand GraphQLupdateAvailabilitySlotboth call it.- No permission added (would lock employees out of their own Schedules page);
SKIPmerge 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 --noEmit0 errors; all 35 static gates ofstatic-checks.ymlpass; 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 /:idandupdateAvailabilitySlotnow 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 bodyemployeerelation 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 anidin 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
nullorganizationIdclears it; omitting it keeps the current value.