Files
familysync/.planning/research/SUMMARY.md
T

116 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project Research Summary
**Project:** FamilySync — v1.2 "Multi-Provider, Theming & Zero-Setup"
**Domain:** Self-hosted family calendar + lists PWA (opening beyond Fastmail to a second provider, on a shipped v1.1)
**Researched:** 2026-06-19
**Confidence:** HIGH for architecture/pitfalls (direct codebase inspection + shipped v1.0/v1.1 lessons); MEDIUM for Google Calendar API specifics (official docs via Context7); LOW for third-party UX patterns (cross-checked websearch)
## Executive Summary
v1.2 opens FamilySync beyond Fastmail. The keystone is a hand-rolled `CalendarProvider` TypeScript interface: the existing tsdav/ical.js CalDAV broker is refactored behind it **as a thin delegation wrapper with zero internal rewrite**, then Google Calendar (Google Calendar API v3 + OAuth2, not CalDAV) plugs in as a second implementation, and an in-memory mock provider plugs in as a third (dev-bypass + hermetic CI). On top of that seam: multiple reminders per event (1→N VALARMs / Google `overrides`), PWA dark mode (Light/Dark/System), and zero-manual-setup DB bootstrap (programmatic `migrate()` at boot). The milestone closes by applying CI-surfaced dependency updates.
The core stack is unchanged. **Two new runtime packages only**`google-auth-library@10.7.0` and `@googleapis/calendar@15.0.0` (scoped, NOT the 50 MB `googleapis` monolith), both in `apps/api`. Dark mode, multiple reminders, migrate-on-boot, and dependency updates add **zero new dependencies** (Zustand `persist`, `ical.js` multi-VALARM, `drizzle-orm/mysql2/migrator`, and existing `pnpm` tooling already cover them).
Three non-negotiable preservation rules drive the design: (1) the provider refactor must **not regress the live Fastmail path** — all v1.1 outbox/poller/scheduler integration tests must pass unchanged against the new interface; (2) the `reminder_lead_minutes``reminder_leads` JSON migration must **migrate existing data** before dropping the old column; (3) Google OAuth tokens are **per-member secrets**, AES-256-GCM encrypted, never in `app_config`, with the `member_credentials` unique constraint widened to `UNIQUE(user_id, provider_type)`.
## Key Findings
### Recommended Stack
No stack change beyond two scoped Google packages (both `apps/api`):
- **`google-auth-library@10.7.0`** — OAuth2 authorization-code flow + offline refresh-token management; ships its own types; `OAuth2Client` auto-refreshes expired access tokens via the `tokens` event.
- **`@googleapis/calendar@15.0.0`** — Google Calendar API v3 typed client (depends only on `googleapis-common`). **Reject the monolithic `googleapis`** (170+ clients, ~50 MB).
- **Provider abstraction** — hand-rolled TS interface in `apps/api/src/broker/`; no normalization library exists worth a dependency.
- **Dark mode** — pure CSS `[data-theme="dark"]` + Zustand `persist` (built into 5.0.14) + an inline `<script>` in `index.html`. No theming library.
- **Migrate-on-boot** — `drizzle-orm/mysql2/migrator` (already transitive); single connection, `multipleStatements: true`. **`drizzle-kit push` stays banned** on MariaDB 11.
- **Dependency updates** — existing `pnpm outdated`/`audit`; selective, pin-aware, one ecosystem at a time.
### Expected Features
**Table stakes:**
- Google events in the unified color-coded view (read sync), recurrence + all-day correct; per-calendar color extends the Phase 17 routing to Google calendar IDs.
- Cross-provider event create/edit/delete with a calendar picker that defaults to the member's most-used calendar and shows recognizable names (not provider IDs/emails).
- Self-service onboarding: Fastmail = app-password form with live CalDAV validation (reuse Phase 10 `CredentialSheet`); Google = single "Connect Google Calendar" OAuth button.
- One-tap reconnect on token expiry, surfaced as a **banner in the calendar view** (not buried in settings) — non-negotiable for the non-technical Apple member.
- Up to 5 reminders per event (add/remove rows, pre-populate all on edit), serialized to VALARMs / Google `overrides`, preserving other-client alarms.
- Light/Dark/System theme toggle in member Settings (per-device localStorage), no flash-of-wrong-theme, clean across every route incl. Schedule-X.
- Migrate-on-boot before `serve()`, idempotent, with a DB-readiness wait and fatal-on-failure.
- Mock provider seeded with recurring/all-day/timed/past/future + reminder events, wired to `DEV_AUTH_BYPASS` for hermetic CI.
**Anti-features (explicitly OUT):** two-way Fastmail↔Google mirroring, CalDAV-for-Google, service-account Google auth, tokens in cookies/localStorage, reminder templates/snooze, per-member server-synced theme, custom theme editor, app self-creating the database, Google per-event `colorId`, `googleapis` monolith.
### Architecture Approach
Grounded in direct codebase inspection. Central seam: `broker/provider.ts` (`CalendarProvider` interface) + `broker/providerFactory.ts` (`createProvider(cred)` dispatching on `member_credentials.provider_type`: `caldav`/`google`/`mock`).
- **CalDavProvider** wraps existing `client.ts`/`sync.ts`/`write.ts` **verbatim** — delegation, not refactor (Anti-Pattern 1).
- **poller.ts / outboxWorker.ts** become provider-agnostic: `createProvider(cred)``provider.listCalendars()`/`syncCalendar()`/`dispatchOutboxRow()`. The `isDraining` guard, `drainRequested` flag, optimistic-202, create-before-delete, and `uid:dtstartMs` dedup all stay in the worker (provider-agnostic) and must be unchanged.
- **GoogleCalendarProvider** — new; inline token refresh (check `expires_at` before each call, refresh within 5 min of expiry, retry-once on 401). Google OAuth callback mounted **pre-auth** (like Authelia `/callback`), at a distinct path with a distinct state cookie, redirect URI built from `EXTERNAL_BASE_URL` (never `req.host`).
- **Token storage** — research surfaces two options: a separate `provider_tokens` table (ARCHITECTURE.md) vs. extending `member_credentials` with `UNIQUE(user_id, provider_type)` (STACK/PITFALLS). Either way the unique constraint must widen to composite; `client_id`/`client_secret`/refresh tokens are env/encrypted, never in `app_config`. **Decide in the provider-abstraction phase.**
- **Multiple reminders** — `calendar_events.reminder_lead_minutes INT``reminder_leads JSON`, with a `JSON_ARRAY(...)` data-migration step before drop; `buildVeventString` loops N VALARMs; scheduler dedup key → `uid:dtstartMs:lead`; Google cap = 5 (reject 6+ before write).
- **Dark mode** — `themeStore.ts` (Zustand persist) + inline FOUC script in `index.html` + `[data-theme="dark"]` block mapping **all** `--sx-color-*` Schedule-X vars + dynamic `theme-color` meta for iOS.
- **Migrate-on-boot** — `runMigrationsIfNeeded()` in the `isMainModule()` guard, after the boot guards, before broker workers + `serve()`; `migration_lock` sentinel for concurrent-startup safety.
- **Mock provider** — `broker/mockProvider.ts`, in-memory, activated via `DEV_AUTH_BYPASS`/`provider_type='mock'`; resets on restart (intentionally stateless).
### Recommended Phase Structure (dependency-ordered, continues from v1.1 Phase 20 → starts at Phase 21)
ARCHITECTURE.md's authoritative ordering:
1. **Phase 21 — Zero-Setup DB Bootstrap.** `runMigrationsIfNeeded()` + `migration_lock` sentinel. No deps; unlocks every later phase to assume auto-migrate. Risk: low.
2. **Phase 22 — Provider Abstraction (CalDAV only, Fastmail unchanged).** Interface + factory + `CalDavProvider` wrapper; poller/outbox use `createProvider`. Dep: 21. **Risk: HIGH** — must not regress Fastmail; golden-path integration tests pass unchanged. Includes the `UNIQUE(user_id, provider_type)` migration.
3. **Phase 23 — Multiple Reminders.** `reminder_leads` JSON + data-migration; multi-VALARM; scheduler multi-lead + dedup-key change; form add/remove. Dep: 22. Risk: MEDIUM (breaking migration).
4. **Phase 24 — Mock Provider (dev/CI).** Seeded in-memory provider; factory `mock` case; Playwright exercises calendar CRUD. Dep: 22. Risk: low.
5. **Phase 25 — Google Calendar Provider.** Token storage + `googleProvider.ts` + OAuth authorize/callback routes + pre-auth mount. Deps: 22, 23 (reminder serialization), 21 (tokens table). Risk: MEDIUM (OAuth edge cases, recurrence/sync model).
6. **Phase 26 — Self-Service Onboarding UI.** `/api/me/providers` GET/DELETE, `ProviderConnectSheet`, reconnect banner. Dep: 25. Risk: low.
7. **Phase 27 — PWA Dark Mode.** FOUC script, dark tokens, `themeStore`, `ThemeToggle`. No deps — **parallelizable with 2226**. Risk: low.
8. **Phase 28 (or folded) — Dependency Updates (DEP-01).** Last; per-ecosystem batches with CI green after each. Risk: low but cascade-prone (ESLint 9.x pin, Schedule-X hold, Drizzle coupling).
`THEME-01` (27) and `SETUP-05` (21) are independent of the multi-provider chain. The roadmapper should confirm whether dark mode and dependency updates are standalone phases or folded.
### Critical Pitfalls (phase-mapped)
1. **Provider refactor regresses the live Fastmail path** (P22) — wrong `provider_type` branch silently stops all write-back; keep `caldav` the default, change zero existing tests.
2. **Google refresh tokens expire after 7 days in "Testing" publishing status** (P25) — use the non-restricted `calendar.readonly` + `calendar.events` scopes + Production status to avoid Google verification; add both accounts as test users; store grant timestamp.
3. **Google recurrence ≠ iCalendar RRULE** (P25) — fetch with `singleEvents: false`, fetch modified/`cancelled` instances separately, map cancellations to EXDATE, reconstruct a VCALENDAR string for the existing `expand.ts`.
4. **Google `syncToken` 410 Gone halts sync + full re-sync overwrites un-drained local writes** (P25) — catch 410 → drop token → enqueue full re-sync; drain outbox (or skip pending UIDs) before upsert.
5. **Google all-day/timezone differs** (P25) — normalize `start.dateTime` to UTC `...Z` (never a TZID without VTIMEZONE); map `reminders.useDefault:true` → null ("no FamilySync reminder").
6. **Multiple VALARM dedup + Google 5-cap** (P23/P25) — dedup key `uid:dtstartMs:lead`; preserve-vs-replace driven by a `remindersChanged` flag; reject 6+ reminders before a Google write.
7. **Drizzle auto-migrate on MariaDB 11 — three failure modes** (P21) — journal-hash mismatch, false destructive diff (only if `push` is misused — use the `migrator` API), concurrent-startup race (the `migration_lock` sentinel); log each applied migration.
8. **Dark mode FOWT + Schedule-X coverage + SW cache** (P27) — inline pre-paint script; override **all** `--sx-color-*`; keep theme CSS in hashed build output.
9. **OAuth through Pangolin — redirect_uri_mismatch + state-cookie collision** (P25/26) — distinct callback path outside the Authelia middleware scope, distinct `google.oauth.state` cookie, redirect URI from `EXTERNAL_BASE_URL`.
10. **Token storage — encryption-key reuse + `UNIQUE(user_id)` blocks multi-provider** (P22/25) — widen to `UNIQUE(user_id, provider_type)`; persist only the encrypted refresh token, cache access tokens in memory; minimum scopes.
11. **Dependency-update breaking cascade** (P28) — ESLint held at 9.x (eslint-plugin-react), Schedule-X minor-only until dark mode locked, Drizzle orm+kit bumped together; never bulk `pnpm up`/`audit --fix`.
## Research Flags (deeper investigation during planning)
- **Provider phase:** token-storage shape (`provider_tokens` table vs. extended `member_credentials`) — pick one in P22; verify the `migrator` API behavior against the installed `drizzle-orm` version before shipping auto-migrate.
- **Google phase:** confirm the exact scope tier needed for write-back without Google verification; end-to-end OAuth test over the Pangolin hostname (not localhost); iOS-Safari standalone callback is load-bearing (human gate, like Phase 3).
- **Reminders phase:** confirm the `remindersChanged`/preserve-on-edit contract end-to-end; index the reminder column for the scheduler scan.
- **Dark mode:** enumerate every `--sx-color-*` from `@schedule-x/theme-default`; verify it's in the Workbox precache (hashed).
## Open Questions to Resolve in Requirements / Roadmap
1. Are **dark mode (27)** and **dependency updates (28)** standalone phases or folded into adjacent work? (Both are independent of the provider chain.)
2. Token storage: separate `provider_tokens` table vs. extended `member_credentials` — resolve in P22.
3. Google all-day "9 AM local" reminder semantics can't be replicated (Google fires midnight-minus-lead) — accept as a documented per-provider difference.
## Confidence
| Domain | Confidence | Notes |
| ------ | ---------- | ----- |
| Stack | HIGH | Two scoped Google packages verified 2026-06-19; everything else reuses shipped deps |
| Features | MEDIUM | Google API via official docs; UX patterns cross-checked but LOW-confidence sources |
| Architecture | HIGH | Grounded in direct v1.1 source; component boundaries + build order sound |
| Pitfalls | HIGH | From codebase review + Google API behavior + v1.0/v1.1 migration/CI lessons, each phase-mapped |
| Google OAuth through Pangolin | MEDIUM | Redirect-URI + state-cookie mechanics sound; needs live end-to-end validation |
## Sources
See `STACK.md`, `FEATURES.md`, `ARCHITECTURE.md`, and `PITFALLS.md` in this directory for full citations (Google Calendar API docs, google-auth-library, Drizzle migrator, Schedule-X theming, pnpm audit/outdated, family-calendar UX surveys, and direct codebase inspection of `apps/api/src/broker/`).
---
_Synthesized for: FamilySync — v1.2 Multi-Provider, Theming & Zero-Setup_
_Researched: 2026-06-19_