feat: facility address (Slice 1 of calendar sync payload) #225

Merged
owlburtoe merged 13 commits from feat/calendar-sync-slice-1-facility-address into main 2026-05-27 23:19:33 -04:00
Owner

Summary

Slice 1 of the Calendar Sync Payload enrichment (spec: docs/superpowers/specs/2026-05-28-calendar-sync-payload-design.md, Section A). Adds a facility postal address so later slices (iOS EventKit sync, ICS feed) can surface a shift LOCATION.

  • Schema: six nullable address columns on facilities (address_line_1/2, city, state, postal_code, country) + committed Drizzle migration 0054_facility_address.sql.
  • API: authenticated dept-scoped GET /api/d/:deptSlug/facility (metadata + address + server-rendered formattedAddress) and PUT /api/d/:deptSlug/facility/address (gated by config:manage, writes the parent facility row, emits a facility.address_updated audit event with prev/next snapshots). Public GET /api/facility stays minimal — no address exposed.
  • Semantics: sparse PUT — omitted keys are preserved, explicit null clears a field, strings are trimmed and empty → null. OpenAPI splits FacilityAddress (full response) from FacilityAddressPatch (sparse request body).
  • Shared types: FacilityAddress + FacilityWithAddress at @schedule-app/shared-types/facility.
  • Frontend: Address card under Settings → Organization (FacilityAddressSection), consuming useFacilityWithAddressQuery / useUpdateFacilityAddressMutation.
  • Test hygiene: rewrote tier6-destructive-permissions.test.ts to mint real signed tokens instead of setTestActor (was flaking under full-suite runs), and tightened the invite allow-case to an exact 400.

Test plan

  • formatAddress unit tests (5) — empty, country-suppression, country-fallback, full, partial intl
  • Facility controller integration tests (6) — GET shape, sparse-preserve, explicit-null-clear, trim/null, 403 gate, audit prev/next
  • Frontend FacilityAddressSection tests (8) — helpers + render states
  • Backend pnpm tsc --noEmit, frontend tsc -b — clean
  • pnpm check:runtime-contracts / check:migration-ordering / check:url-generation — pass
  • pnpm smoke:backend-runtime/api/ready 200
  • Frontend full suite — 701/701
  • Backend full suite — 1433/1434; the single failure is a pre-existing, non-deterministic suite-isolation flake (different victim each run, passes in isolation, reproduces on main) tracked separately — not introduced by this branch
## Summary Slice 1 of the Calendar Sync Payload enrichment (spec: `docs/superpowers/specs/2026-05-28-calendar-sync-payload-design.md`, Section A). Adds a facility postal address so later slices (iOS EventKit sync, ICS feed) can surface a shift `LOCATION`. - **Schema**: six nullable address columns on `facilities` (`address_line_1/2`, `city`, `state`, `postal_code`, `country`) + committed Drizzle migration `0054_facility_address.sql`. - **API**: authenticated dept-scoped `GET /api/d/:deptSlug/facility` (metadata + address + server-rendered `formattedAddress`) and `PUT /api/d/:deptSlug/facility/address` (gated by `config:manage`, writes the parent facility row, emits a `facility.address_updated` audit event with prev/next snapshots). Public `GET /api/facility` stays minimal — no address exposed. - **Semantics**: sparse PUT — omitted keys are preserved, explicit `null` clears a field, strings are trimmed and empty → null. OpenAPI splits `FacilityAddress` (full response) from `FacilityAddressPatch` (sparse request body). - **Shared types**: `FacilityAddress` + `FacilityWithAddress` at `@schedule-app/shared-types/facility`. - **Frontend**: Address card under Settings → Organization (`FacilityAddressSection`), consuming `useFacilityWithAddressQuery` / `useUpdateFacilityAddressMutation`. - **Test hygiene**: rewrote `tier6-destructive-permissions.test.ts` to mint real signed tokens instead of `setTestActor` (was flaking under full-suite runs), and tightened the invite allow-case to an exact 400. ## Test plan - [x] `formatAddress` unit tests (5) — empty, country-suppression, country-fallback, full, partial intl - [x] Facility controller integration tests (6) — GET shape, sparse-preserve, explicit-null-clear, trim/null, 403 gate, audit prev/next - [x] Frontend `FacilityAddressSection` tests (8) — helpers + render states - [x] Backend `pnpm tsc --noEmit`, frontend `tsc -b` — clean - [x] `pnpm check:runtime-contracts` / `check:migration-ordering` / `check:url-generation` — pass - [x] `pnpm smoke:backend-runtime` — `/api/ready` 200 - [x] Frontend full suite — 701/701 - [x] Backend full suite — 1433/1434; the single failure is a pre-existing, non-deterministic suite-isolation flake (different victim each run, passes in isolation, reproduces on main) tracked separately — not introduced by this branch
Pure helper that renders facility address parts into a single-line string.
Behavior:
- Returns empty string when all address parts are null/empty
- Omits country-only addresses (country not enough to form valid address)
- Defaults to "United States" when other parts present but country missing
- Formats city + state/postal as "City, ST ZIP" (omits missing parts)
- Joins non-empty segments with comma-space separator

Includes 5 fixture-driven tests covering empty, country-only suppression,
country fallback, complete address with line 2, and partial international.
Previously the PUT controller coerced every undefined field to null and
the repo wrote all six columns verbatim, silently clearing any field a
client omitted from a sparse body. The frontend always sent the full
form so the regression never fired, but future clients (iOS sync, ICS)
would have nulled out columns on partial updates.

- Controller now passes only the fields present in the parsed body.
- Repo accepts Partial<>, builds the SET clause from provided keys, and
  returns audit-next as previous merged with the input.
- OpenAPI splits FacilityAddress (response, all keys required+nullable)
  from new FacilityAddressPatch (sparse update body, all keys optional).
- Two regression tests:
  - sparse PUT preserves untouched columns (read-back + persisted row)
  - explicit null clears the targeted field without affecting others
test(backend): make tier6 permission test hermetic
All checks were successful
Code Scanning / Gitleaks secret scan (pull_request) Successful in 7s
Code Scanning / Semgrep OSS source scan (pull_request) Successful in 33s
Security, Type Check & Runtime / Dependency Audit (pull_request) Successful in 9m38s
Security, Type Check & Runtime / Type Check (pull_request) Successful in 10m6s
Security, Type Check & Runtime / Migration Guardrails (pull_request) Successful in 9m36s
Security, Type Check & Runtime / Backend Runtime Smoke (pull_request) Successful in 10m9s
E2E Tests / e2e (pull_request) Successful in 14m34s
daad4b953f
The tier6 destructive-permissions suite relied on ambient globals
(deleted JWT_SECRET, module-global setTestActor, implicit localhost
tenancy) so it was order-sensitive and flaked under full-suite runs
with a transport-level parse error.

Rewrite to mint a real signed actor token per request over
Host: api.getshiftd.com (shared-API tenancy Path B), pinning the pv
claim to the live department matrix version so enforcePermissionsVersion
passes before the permission gate under test. Also tighten the invite
allow-case from '!== 403' to an exact 400 (empty body fails validation
after the gate).
owlburtoe deleted branch feat/calendar-sync-slice-1-facility-address 2026-05-27 23:19:33 -04:00
Sign in to join this conversation.
No description provided.