# Phase 3: Event Write-Back + PWA Install - Pattern Map **Mapped:** 2026-06-05 **Files analyzed:** 12 new/modified files **Analogs found:** 10 / 12 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `apps/api/src/db/schema.ts` | model | CRUD | `apps/api/src/db/schema.ts` (extend existing) | exact | | `apps/api/src/broker/write.ts` | service | request-response | `apps/api/src/broker/client.ts` | role-match | | `apps/api/src/broker/vevent.ts` | utility | transform | `apps/api/src/broker/sync.ts` (ical.js usage) | role-match | | `apps/api/src/broker/outboxWorker.ts` | service | batch | `apps/api/src/broker/poller.ts` | exact | | `apps/api/src/routes/events.ts` | route | request-response | `apps/api/src/routes/events.ts` (extend existing) | exact | | `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` (extend) | exact | | `apps/pwa/src/components/EventForm.tsx` | component | request-response | `apps/pwa/src/components/EventDetailPopover.tsx` | role-match | | `apps/pwa/src/components/InstallPrompt.tsx` | component | event-driven | `apps/pwa/src/components/EmptyState.tsx` | partial | | `apps/pwa/src/api/client.ts` | utility | request-response | `apps/pwa/src/api/client.ts` (extend existing) | exact | | `apps/pwa/vite.config.ts` | config | — | `apps/pwa/vite.config.ts` (extend existing) | exact | | `apps/api/tests/broker/outboxWorker.test.ts` | test | batch | `apps/api/tests/broker/sync.test.ts` | role-match | | `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/routes/events.test.ts` (extend) | exact | --- ## Pattern Assignments ### `apps/api/src/db/schema.ts` — add `calendarOutbox` table + `objectUrl` column on `calendarEvents` **Analog:** `apps/api/src/db/schema.ts` (lines 1–112, existing file) **Imports pattern** (lines 1–12): ```typescript import { mysqlTable, varchar, text, int, date, timestamp, boolean, index, unique, } from 'drizzle-orm/mysql-core' ``` Add `mysqlEnum` to the import list — already used in the research pattern but not yet in schema.ts. **Existing table pattern** (lines 86–112) — copy this structure for `calendarOutbox`: ```typescript export const calendarEvents = mysqlTable( 'calendar_events', { id: int().primaryKey().autoincrement(), calendarId: int('calendar_id') .notNull() .references(() => calendars.id, { onDelete: 'cascade' }), uid: varchar('uid', { length: 512 }).notNull(), etag: varchar('etag', { length: 256 }), // ... updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), }, (t) => [ index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc), unique('uniq_calendar_uid').on(t.calendarId, t.uid), ], ) ``` **New column on `calendarEvents`** — add `objectUrl` after `etag`: ```typescript objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated by sync.ts from obj.url ``` **References pattern** (lines 40–47) — copy for `calendarOutbox.userId`: ```typescript userId: int('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), ``` --- ### `apps/api/src/broker/write.ts` — new file, tsdav PUT/DELETE wrapper **Analog:** `apps/api/src/broker/client.ts` (lines 1–32) **File header and imports pattern** (client.ts lines 1–12): ```typescript /** * [JSDoc comment with source citations] * Source: https://... */ import { createDAVClient } from 'tsdav' export type FastmailClient = Awaited> ``` **Export pattern** — named exports, no default (matches all broker files): ```typescript import type { FastmailClient } from './client.js' import type { DAVCalendar } from 'tsdav' export async function createCalendarEvent(...): Promise { ... } export async function updateCalendarEvent(...): Promise { ... } export async function deleteCalendarEvent(...): Promise { ... } ``` **Import extension `.js`** — all broker imports use `.js` suffix (e.g., `'./client.js'`, `'../db/client.js'`). Required for ESM with TypeScript. --- ### `apps/api/src/broker/vevent.ts` — new file, ical.js VEVENT builder **Analog:** `apps/api/src/broker/sync.ts` (lines 1–127) — existing ical.js usage **ical.js import pattern** (sync.ts line 20): ```typescript import ICAL from 'ical.js' ``` **ical.js parse → component pattern** (sync.ts lines 72–86) — the reverse direction (build vs parse) uses the same ICAL.Component/ICAL.Time API: ```typescript const comp = new ICAL.Component(parsed) const vevent = comp.getFirstSubcomponent('vevent') const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null ``` **D-13 all-day vs timed split** (sync.ts lines 89–101) — must mirror this exact split in the builder: ```typescript // D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column const allDay: boolean = dtstart?.isDate ?? false const dtstartDateValue: Date | null = allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null ``` **Error isolation pattern** (sync.ts lines 74–78): ```typescript try { parsed = ICAL.parse(obj.data as string) } catch { // Malformed VCALENDAR — skip but do not crash the sync continue } ``` --- ### `apps/api/src/broker/outboxWorker.ts` — new file, outbox drain loop **Analog:** `apps/api/src/broker/poller.ts` (lines 1–85) — closest match, exact role **File header JSDoc pattern** (poller.ts lines 1–16): ```typescript /** * CalDAV broker poller — runs every 5 minutes via node-cron. * * Responsibilities (D-13, D-02): * - ... * * runPoll is exported for unit testing (inject mocks via vi.mock at the module level). * startBrokerPoller wraps it in node-cron's 5-minute schedule. * * Source: https://github.com/node-cron/node-cron (v4 stable basic API) */ ``` **Imports pattern** (poller.ts lines 18–25): ```typescript import { schedule } from 'node-cron' import { eq } from 'drizzle-orm' import { db } from '../db/client.js' import { memberCredentials, calendars } from '../db/schema.js' import { decryptPassword } from './crypto.js' import { createFastmailClient } from './client.js' import { syncCalendar } from './sync.js' ``` Replace with: `and`, `lte`, `eq` from `drizzle-orm`; `calendarOutbox`, `calendars` from schema; `syncCalendar` from `./sync.js`; write functions from `./write.js`. **Exported runX + startX pair pattern** (poller.ts lines 35–85): ```typescript // runPoll exported for unit testing export async function runPoll(): Promise { ... } // startBrokerPoller wraps it in a schedule export function startBrokerPoller(): void { schedule('*/5 * * * *', () => { runPoll().catch((err: unknown) => { console.error('[broker/poller] Unhandled runPoll error:', err) }) }) } ``` Outbox worker follows: `export async function runOutboxDrain()` + `export function startOutboxWorker()`. **Per-item error isolation pattern** (poller.ts lines 65–72): ```typescript } catch (err) { // Log the error but do NOT log the app password or key (T-03-04) console.error( `[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`, err instanceof Error ? err.message : String(err), ) } ``` **Drizzle select + where + limit pattern** (poller.ts lines 47–53): ```typescript const [stored] = await db .select() .from(calendars) .where(eq(calendars.url, davCal.url)) .limit(1) ``` **Drizzle update pattern** — extend from sync.ts `onDuplicateKeyUpdate` shape: ```typescript await db.update(calendarOutbox) .set({ status: 'done' }) .where(eq(calendarOutbox.id, row.id)) ``` --- ### `apps/api/src/routes/events.ts` — extend with write endpoints + sync-status **Analog:** `apps/api/src/routes/events.ts` (lines 1–141, existing file) **File header invariant comment** (lines 1–15) — copy verbatim and extend: ```typescript /** * Architecture invariant (T-03-02, broker-boundary): * This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly. * All Fastmail I/O is owned exclusively by the broker module (src/broker/). * No tsdav import here; no createFastmailClient import here. */ ``` **Hono router + zValidator pattern** (lines 17–41): ```typescript import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { and, or, eq, lte, lt } from 'drizzle-orm' import { sql } from 'drizzle-orm' import { db } from '../db/client.js' import { calendarEvents, calendars, users } from '../db/schema.js' export const eventsRouter = new Hono() const eventsQuerySchema = z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }) ``` **Route handler + zValidator + try/catch error pattern** (lines 53–141): ```typescript eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { // ... input validation ... try { const rows = await db.select(...).from(...).where(...) return c.json({ occurrences: allOccurrences }) } catch (err) { console.error('[events] DB query or expansion failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) ``` New write endpoints follow the same shape: `eventsRouter.post('/create', zValidator('json', createSchema), async (c) => { ... })`. **Auth identity pattern** (from me.ts lines 33–44) — write endpoints need current user: ```typescript const devUser = c.get('user') if (devUser) { // dev bypass path } const auth = await getAuth(c) if (!auth) { return c.json({ error: 'Unauthorized' }, 401) } ``` --- ### `apps/pwa/src/components/EventDetailPopover.tsx` — add edit/delete to reserved footer **Analog:** `apps/pwa/src/components/EventDetailPopover.tsx` (lines 380–388, reserved footer) **Reserved footer (lines 380–388)** — Phase 3 wires buttons here: ```tsx {/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}