diff --git a/internal/db/migrations/101_caldav_multi_calendar.down.sql b/internal/db/migrations/101_caldav_multi_calendar.down.sql new file mode 100644 index 0000000..d525854 --- /dev/null +++ b/internal/db/migrations/101_caldav_multi_calendar.down.sql @@ -0,0 +1,13 @@ +-- Reverse of 101_caldav_multi_calendar.up.sql. +-- +-- Drop the new join + binding tables. CASCADE on the FK references +-- isn't needed because we drop targets before bindings, and Postgres +-- handles RLS policies / indexes automatically on DROP TABLE. +-- +-- The legacy paliad.appointments.caldav_uid / caldav_etag columns are +-- untouched by the up migration, so they're untouched here too — +-- rollback returns the system to the pre-Slice-1 state where those +-- scalars are the single source of CalDAV truth. + +DROP TABLE IF EXISTS paliad.appointment_caldav_targets; +DROP TABLE IF EXISTS paliad.user_calendar_bindings; diff --git a/internal/db/migrations/101_caldav_multi_calendar.up.sql b/internal/db/migrations/101_caldav_multi_calendar.up.sql new file mode 100644 index 0000000..bb7c577 --- /dev/null +++ b/internal/db/migrations/101_caldav_multi_calendar.up.sql @@ -0,0 +1,350 @@ +-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see +-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema + +-- backfill; the sync engine is NOT touched in this migration. Slice 2 +-- wires the per-binding fan-out. +-- +-- What we add: +-- 1. paliad.user_calendar_bindings — N bindings per user, each with +-- a scope_kind enum (all_visible / personal_only / project / +-- client / litigation / patent / case) and an optional scope_id +-- pointing at a paliad.projects row when the scope is hierarchy- +-- anchored. The same Appointment can be PUT into multiple of +-- these bindings (e.g. master cal + per-project cal). +-- 2. paliad.appointment_caldav_targets — (appointment_id, binding_id) +-- join carrying the per-target caldav_uid + caldav_etag. The +-- canonical UID is still per-appointment (paliad-appointment- +-- @paliad.de) so the same event in N cals shares one UID. +-- 3. Backfill: one all_visible binding per existing +-- user_caldav_config row, plus one target row per Appointment +-- already pushed (caldav_uid IS NOT NULL). Backfill maps the +-- target's binding_id to the appointment creator's binding — +-- that matches today's Phase F semantics, where the creator's +-- sync goroutine owns the etag. +-- +-- The scalar columns paliad.appointments.caldav_uid / caldav_etag +-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as +-- read-once denormalised pointers to the default binding's target +-- row; Slice 4 drops them after telemetry confirms no path still +-- reads them. +-- +-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are +-- guarded by NOT EXISTS. Safe to re-run. +-- +-- audit_reason set_config required at the top because m's recent +-- migration friction had several mig failures from missing reasons. +-- The trigger raising 'audit reason required' is on +-- paliad.deadline_rules only — this migration doesn't touch that +-- table — but we set the reason for symmetry per paliadin's 2026-05-19 +-- coder-shift brief. + +SELECT set_config( + 'paliad.audit_reason', + 'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.', + true); + +-- ========================================================================= +-- 1. paliad.user_calendar_bindings +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Full URL or path under user_caldav_config.url. The CalDAV client + -- resolves it against the user's server URL the same way it + -- resolves the legacy user_caldav_config.calendar_path today. + calendar_path text NOT NULL, + + -- What the picker UI shows for this binding. Discovered via + -- PROPFIND at add-time and cached here so we don't + -- re-fetch every render. Default '' (Slice 1 backfill leaves it + -- empty; Slice 2 fills it during the picker flow). + display_name text NOT NULL DEFAULT '', + + -- Which appointments push into this calendar. Slice 1 only really + -- needs 'all_visible' (that's all the backfill creates) but we + -- ship the full enum now so the schema is final and Slice 2/3 + -- don't have to ALTER it. + scope_kind text NOT NULL, + scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE, + + -- Only meaningful when scope_kind is hierarchy-anchored + -- (project / client / litigation / patent / case). When true, + -- the binding ALSO receives the user's personal (project_id IS + -- NULL AND created_by = user_id) appointments. Ignored for + -- 'all_visible' (already includes them) and 'personal_only'. + include_personal boolean NOT NULL DEFAULT false, + + enabled boolean NOT NULL DEFAULT true, + last_sync_at timestamptz, + last_sync_error text, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK ( + scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case') + ), + CONSTRAINT user_calendar_bindings_scope_id_chk CHECK ( + (scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL) + OR + (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL) + ) +); + +-- One binding per (user, calendar) — can't bind the same external +-- calendar twice for the same user. +CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq + ON paliad.user_calendar_bindings (user_id, calendar_path); + +-- One hierarchy binding per (user, scope_kind, scope_id) — a user +-- can't have two bindings for the same project, but CAN have a +-- 'project' binding for project X alongside an 'all_visible' +-- master binding (different scope_kind ⇒ different row). +CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq + ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id) + WHERE scope_id IS NOT NULL; + +-- One scope-less binding per (user, scope_kind) — at most one +-- 'all_visible' and one 'personal_only' per user. +CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq + ON paliad.user_calendar_bindings (user_id, scope_kind) + WHERE scope_id IS NULL; + +CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx + ON paliad.user_calendar_bindings (user_id) + WHERE enabled; + +-- No updated_at trigger — paliad.user_caldav_config also doesn't have +-- one. The Go service layer sets updated_at = NOW() explicitly on +-- every write (see SaveConfig in caldav_service.go); we follow the +-- same convention here so all CalDAV-related tables are consistent. + +ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY; + +-- Same shape as user_caldav_config policies: a user sees + mutates +-- only their own rows. auth.uid() returns the authenticated user's +-- id (mirrors auth.uid()). +DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings; +CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings + FOR SELECT TO authenticated + USING (user_id = auth.uid()); + +DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings; +CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings + FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()); + +DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings; +CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings + FOR UPDATE TO authenticated + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings; +CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings + FOR DELETE TO authenticated + USING (user_id = auth.uid()); + + +-- ========================================================================= +-- 2. paliad.appointment_caldav_targets +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets ( + appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE, + binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE, + + -- 'paliad-appointment-@paliad.de' — derived from + -- appointment_id, identical across all bindings of one appointment. + caldav_uid text NOT NULL, + + -- ETag returned by the CalDAV server on the last successful PUT. + -- NULLABLE to match the legacy paliad.appointments.caldav_etag + -- column: some servers don't return ETag on PUT and we + -- re-PROPFIND lazily on next tick. + caldav_etag text, + + last_pushed_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (appointment_id, binding_id) +); + +CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx + ON paliad.appointment_caldav_targets (binding_id); + +CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx + ON paliad.appointment_caldav_targets (caldav_uid); + +ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY; + +-- A target row is visible/mutable to the user who owns the binding. +-- Appointment-side visibility is enforced separately by AppointmentService; +-- the target is a sync-state row, scoped per-user. +DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets; +CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets + FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.id = appointment_caldav_targets.binding_id + AND b.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets; +CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets + FOR INSERT TO authenticated + WITH CHECK (EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.id = appointment_caldav_targets.binding_id + AND b.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets; +CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets + FOR UPDATE TO authenticated + USING (EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.id = appointment_caldav_targets.binding_id + AND b.user_id = auth.uid() + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.id = appointment_caldav_targets.binding_id + AND b.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets; +CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets + FOR DELETE TO authenticated + USING (EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.id = appointment_caldav_targets.binding_id + AND b.user_id = auth.uid() + )); + + +-- ========================================================================= +-- 3. Backfill — one all_visible binding per existing CalDAV-configured user +-- ========================================================================= + +-- For every paliad.user_caldav_config row, insert an 'all_visible' +-- binding that mirrors today's single-target Phase F push. The new +-- binding inherits the legacy `calendar_path` (or, when that's empty, +-- the server URL itself — same fallback the client uses today). The +-- enabled flag carries over. +-- +-- Idempotent: skipped when this user already has an all_visible binding +-- (re-running the migration is a no-op). +INSERT INTO paliad.user_calendar_bindings + (user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled) +SELECT + c.user_id, + COALESCE(NULLIF(c.calendar_path, ''), c.url), + '', + 'all_visible', + NULL, + false, + c.enabled +FROM paliad.user_caldav_config c +WHERE NOT EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.user_id = c.user_id + AND b.scope_kind = 'all_visible' +); + + +-- ========================================================================= +-- 4. Backfill — one target row per already-pushed appointment +-- ========================================================================= + +-- For every appointment with a non-null caldav_uid, insert one target +-- row pointing at the appointment creator's new all_visible binding. +-- That preserves the (appointment, calendar) sync state exactly as it +-- existed before this migration. +-- +-- Why created_by, not "every visible user": today's Phase F +-- caldav_uid/caldav_etag scalars on appointments are populated by +-- whoever happened to push last; in practice the etag almost always +-- belongs to the creator's calendar because pull-side updates only +-- run when CreatedBy = userID (caldav_service.go:449). Mapping the +-- backfill target to the creator's binding keeps the etag pointing +-- where it actually came from. Other users' goroutines will create +-- their own target rows on their next sync tick after Slice 2 ships. +-- +-- Idempotent: skipped when (appointment_id, binding_id) target already +-- exists. +INSERT INTO paliad.appointment_caldav_targets + (appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at) +SELECT + a.id, + b.id, + a.caldav_uid, + a.caldav_etag, + a.updated_at +FROM paliad.appointments a +JOIN paliad.user_calendar_bindings b + ON b.user_id = a.created_by + AND b.scope_kind = 'all_visible' +WHERE a.caldav_uid IS NOT NULL + AND a.created_by IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM paliad.appointment_caldav_targets t + WHERE t.appointment_id = a.id + AND t.binding_id = b.id + ); + + +-- ========================================================================= +-- 5. Assertions — hard fail if the backfill didn't catch every row +-- ========================================================================= + +-- Every paliad.user_caldav_config row must have at least one +-- all_visible binding after this migration. If it doesn't, either a +-- row was inserted between the backfill and the assertion (race — +-- run is wrapped in a transaction by golang-migrate, so this can't +-- happen) or the backfill is buggy. Hard fail either way. +DO $$ +DECLARE + missing_users int; +BEGIN + SELECT count(*) INTO missing_users + FROM paliad.user_caldav_config c + WHERE NOT EXISTS ( + SELECT 1 FROM paliad.user_calendar_bindings b + WHERE b.user_id = c.user_id + AND b.scope_kind = 'all_visible' + ); + IF missing_users > 0 THEN + RAISE EXCEPTION + 'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding', + missing_users; + END IF; +END $$; + +-- Every appointment with a non-null caldav_uid AND a non-null +-- created_by must have a target row pointing at its creator's +-- all_visible binding. created_by can be NULL on legacy rows +-- (e.g. seed data) so we exclude those from the assertion. +DO $$ +DECLARE + missing_targets int; +BEGIN + SELECT count(*) INTO missing_targets + FROM paliad.appointments a + WHERE a.caldav_uid IS NOT NULL + AND a.created_by IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM paliad.appointment_caldav_targets t + JOIN paliad.user_calendar_bindings b + ON b.id = t.binding_id + WHERE t.appointment_id = a.id + AND b.user_id = a.created_by + AND b.scope_kind = 'all_visible' + ); + IF missing_targets > 0 THEN + RAISE EXCEPTION + 'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row', + missing_targets; + END IF; +END $$;