docs(03-03): complete write API surface plan — enqueue-only endpoints GREEN

This commit is contained in:
Lucas Berger
2026-06-05 17:59:53 -04:00
parent 0a8222329e
commit 7398946aa8
16 changed files with 2168 additions and 10 deletions
@@ -0,0 +1,143 @@
---
phase: 03-event-write-back-pwa-install
plan: 03
subsystem: api
tags: [hono, drizzle, zod, calendarOutbox, write-back, outbox-pattern, access-control, tdd]
requires:
- phase: 03-event-write-back-pwa-install/03-01
provides: calendarOutbox schema + calendarEvents.objectUrl + Wave-0 RED test scaffold
- phase: 03-event-write-back-pwa-install/03-02
provides: broker primitives (vevent.ts, write.ts) — not used by routes but confirm broker boundary
provides:
- POST /api/events/create — validates, checks D-03 ownership, enqueues pending outbox row, returns 202 with uid
- PATCH /api/events/:uid/edit — looks up event, checks ownership, enqueues update or transaction-paired delete+create for calendar moves
- DELETE /api/events/:uid — looks up event, checks ownership, enqueues delete row with server-side etag
- GET /api/events/sync-status — member-scoped outbox status poll (D-09)
- GET /api/events/writable-calendars — authoritative D-03 writable set (own personal + shared Family; never other member's personal)
- zod schemas for event fields (title 255, location/description 2000 — T-03-08 bounds)
affects:
- 03-04 (outbox worker drains rows these endpoints enqueue)
- 03-05 (EventForm + client.ts consume these endpoints + writable-calendars)
tech-stack:
added: []
patterns:
- "resolveUserId(c): dev-bypass c.get('user') first, fallback to getAuth(c) for OIDC — same pattern as me.ts"
- "Enqueue-only write endpoints: no Fastmail call in routes; db.insert(calendarOutbox) is the only side effect"
- "Edit-as-move: db.transaction with paired delete+create sharing a groupId (D-04)"
- "sync-status: .orderBy(desc(createdAt)).limit(1) to get latest outbox row; userId-scoped (T-03-07)"
- "writable-calendars: WHERE userId=currentUser.id OR isShared=1 — authoritative D-03 enforcement (T-03-11)"
- "Test mock pattern for db.transaction: factory fn cb receives mock tx with insert; vi.mock hoisted factory captures mutable refs"
- "devAuthBypass mock in tests: vi.mock('../auth/devBypass.js') injects dev user so write tests get authenticated context"
key-files:
created: []
modified:
- apps/api/src/routes/events.ts
- apps/api/tests/routes/events.test.ts
key-decisions:
- "resolveUserId helper uses any type to avoid Hono context generic complexity — acceptable for internal helper"
- "Two-query ownership check for edit/delete (get event, then check calendar isShared) to maintain simple from().where() chain that test mocks can intercept without innerJoin complexity"
- "Writable-calendars response maps to { url, displayName, color, isShared } — the Plan 05 WritableCalendar shape"
- "sync-status returns { uid, status: 'done' } when no outbox row found (nothing pending = settled)"
patterns-established:
- "Enqueue-only write route: validate → check ownership → db.insert(calendarOutbox) → return 202; no broker call"
- "D-03 ownership enforcement at two layers: write endpoints AND writable-calendars listing"
- "vi.mock devAuthBypass for write-endpoint tests avoids needing ENV manipulation or OIDC infrastructure"
requirements-completed: [CAL-04, CAL-05, CAL-06, CAL-07]
duration: 7min
completed: 2026-06-05
---
# Phase 03 Plan 03: Write API Surface Summary
**Hono write endpoints (create/edit/delete + sync-status + writable-calendars) enqueue to calendarOutbox with D-03 ownership enforcement; zod-validated, 202 optimistic-accept, no Fastmail call**
## Performance
- **Duration:** ~7 min
- **Started:** 2026-06-05T17:51:00Z
- **Completed:** 2026-06-05T21:58:08Z
- **Tasks:** 3 (Tasks 1-2-3 implemented in one feat commit; TDD RED gate committed separately)
- **Files modified:** 2
## Accomplishments
- All five write/status/writable-calendars endpoints enqueue-only and member-scoped
- D-03 ownership enforced on both the write path and the writable-calendars listing
- D-04 edit-as-move pair implemented transactionally (db.transaction with shared groupId)
- D-09 polling endpoint (sync-status) live with strict userId scoping (T-03-07)
- Broker boundary preserved: no tsdav import in routes/events.ts
## Task Commits
1. **RED gate**`e14c5da` (test): extend events tests — write/sync-status/writable-calendars endpoints (9 new failing tests)
2. **GREEN + Tasks 1/2/3**`0a82223` (feat): implement write API surface — all 69 events tests GREEN, tsc clean
## Files Created/Modified
- `apps/api/src/routes/events.ts` — extended with POST /create, PATCH /:uid/edit, DELETE /:uid, GET /sync-status, GET /writable-calendars; auth helper; zod schemas; `db.transaction` for edit-as-move
- `apps/api/tests/routes/events.test.ts` — extended with 9 new write-endpoint tests; wired db.insert + db.transaction into vi.mock; added devAuthBypass mock for auth injection
## Decisions Made
- **resolveUserId uses `any` type:** Hono's generic context type is complex to thread through a standalone helper; `any` is acceptable for an internal module-private helper that does a simple property access.
- **Two-query ownership check for edit/delete:** Rather than innerJoin (which would break the flat from().where() mock chain in tests), the implementation does a second query on calendars to check isShared when the event's userId doesn't match. Both queries share the same mock chain in tests, which works because both return the seeded mockDbRows.
- **writable-calendars response shape:** `{ url, displayName, color, isShared }` matches the `WritableCalendar` shape Plan 05's `fetchWritableCalendars` expects.
- **sync-status default to 'done':** When no outbox row exists for a UID, the endpoint returns `{ uid, status: 'done' }` — nothing pending means the event is settled.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] TypeScript error on resolveUserId helper**
- **Found during:** Task 1 (implementation) — tsc --noEmit reported TS2493/TS2339 on complex Hono context type inference
- **Issue:** The helper function tried to infer the Hono context type from `eventsRouter.get` parameters, which failed due to tuple type length mismatch
- **Fix:** Changed helper parameter to `any` with inline cast; added clarifying comment
- **Files modified:** apps/api/src/routes/events.ts
- **Verification:** `tsc --noEmit` passes clean
- **Committed in:** 0a82223
---
**Total deviations:** 1 auto-fixed (Rule 1 - type error)
**Impact on plan:** Minor typing accommodation; no behavior change.
## Issues Encountered
- Test mock architecture required careful design: the existing `vi.mock` for db/client.js only mocked `db.select`; extending it to include `db.insert` and `db.transaction` required restructuring the mock factory to use mutable `vi.fn()` references that can be reassigned in `beforeEach`. The devAuthBypass mock was added to give write-endpoint tests an authenticated user context without ENV manipulation.
## Known Stubs
None — all endpoints are fully wired to the DB schema. The outbox rows they insert will be drained by the Plan 04 worker; until that plan runs, rows accumulate in pending state (correct behavior).
## Threat Flags
No new network endpoints or auth paths beyond what is in the plan's threat model. All T-03-06 through T-03-11 mitigations are implemented.
## Self-Check
- [x] `apps/api/src/routes/events.ts` exists and includes all 5 endpoints
- [x] `apps/api/tests/routes/events.test.ts` exists and tests are GREEN (69 passed)
- [x] Commits e14c5da (test RED) and 0a82223 (feat GREEN) exist
- [x] `grep -c "tsdav\|createFastmailClient" apps/api/src/routes/events.ts` = 1 (comment only, not import)
- [x] `grep -c "db.transaction" apps/api/src/routes/events.ts` = 1
- [x] tsc --noEmit passes clean
## Self-Check: PASSED
## Next Phase Readiness
- Plan 04 (outbox worker): `calendarOutbox` rows are being enqueued; worker can now drain them
- Plan 05 (EventForm + client.ts): POST /create, PATCH /:uid/edit, DELETE /:uid endpoints are live; GET /writable-calendars provides the picker data
---
*Phase: 03-event-write-back-pwa-install*
*Completed: 2026-06-05*