Files
paliad/internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
mAi 694c7a53ad feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar
calendar_path to the binding-row model introduced in Slice 1
(mig 101). Invisible-but-shippable: existing Phase F users keep
their backfilled all_visible binding, new users hitting the legacy
PUT /api/caldav-config get an auto-created all_visible binding so
the "configure → it just works" UX survives. Slice 2b adds the
picker UI and write APIs on top.

Schema (mig 107)
- paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL
  so audit history survives binding deletes).
- Per-binding index for the read path.
- Idempotent (column-exists DO block) + assertion.

Services
- CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled,
  Get, Create, Update, Delete, SetSyncStatus. Mirrors the table
  CHECK constraints client-side so the API returns useful 400s.
- AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding,
  ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding.
  Replaces SetCalDAVMeta as the authoritative source of per-target
  state; legacy scalar columns still written for back-compat.
- AppointmentService.ForBinding: scope filter implementing
  all_visible, personal_only, project. Hierarchy scopes
  (client/litigation/patent/case) return ErrUnsupportedScope —
  Slice 3 wires them via the existing path-based descendant
  predicate.

Sync engine rewrite
- CalDAVService.Start iterates ListAllEnabled to discover users
  with at least one enabled binding.
- runSyncOnce loops bindings, writes one caldav_sync_log row per
  (user, binding) tick, rolls the worst-case error up onto
  user_caldav_config.last_sync_error so /api/caldav-config still
  shows aggregate status.
- pushBinding pushes the ForBinding() slice + cleans up
  stale-target rows (project unshared, scope PATCHed).
- pullBinding swaps the N×GET pattern for REPORT calendar-multiget
  (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate
  limits) and reconciles via per-target etag comparison.
- Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the
  user's matching bindings using appointmentInBinding() — best
  effort per binding, same 30s timeout as Phase F.
- SaveConfig auto-creates an all_visible binding on first-time
  configure so Phase F "configure → events appear" survives the
  cut-over.

CalDAV client
- New ReportMultiget verb implementing RFC 4791 §7.9
  calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google
  Calendar's per-request cap.

HTTP API
- GET /api/caldav-bindings — read-only list of the authenticated
  user's bindings. Slice 2b adds POST/PATCH/DELETE.

Verification
- BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies
  cleanly + the synthetic two-binding scenario lands the project
  appointment in both bindings while keeping the personal one in
  master only; cascade on appointment-delete drops targets; cascade
  on binding-delete drops targets AND sets sync_log.binding_id NULL.
- go build ./..., go test ./internal/..., bun run build all clean.

Backwards-compat
- paliad.appointments.caldav_uid / caldav_etag still written in
  pushBinding so legacy readers see fresh values. Slice 4 drops
  them after telemetry confirms no path still reads them.
2026-05-20 13:05:27 +02:00

54 lines
1.9 KiB
SQL

-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
--
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
-- records which binding the entry belongs to. NULL for legacy rows
-- and for "global" log entries that aren't per-binding (Slice 2a
-- still writes one row per user per tick — Slice 2b's sync rewrite
-- moves to one row per (user, binding) per tick).
--
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
-- its historical sync log (audit trail wins over referential tidiness).
--
-- Idempotent: column added via DO block with information_schema check.
SELECT set_config(
'paliad.audit_reason',
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id'
) THEN
ALTER TABLE paliad.caldav_sync_log
ADD COLUMN binding_id uuid
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
WHERE binding_id IS NOT NULL;
-- Assertion: column exists and is nullable.
DO $$
DECLARE
col_nullable text;
BEGIN
SELECT is_nullable INTO col_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id';
IF col_nullable IS NULL THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
END IF;
IF col_nullable <> 'YES' THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
END IF;
END $$;