Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:
- paliad.user_calendar_bindings — N bindings per user with scope_kind
∈ {all_visible, personal_only, project, client, litigation, patent,
case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
Partial unique indexes enforce one binding per (user, scope_kind,
scope_id) for hierarchical scopes and one per (user, scope_kind)
for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
carrying caldav_uid + caldav_etag. UID stays canonical per
appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
row, one target row per appointment already pushed. Maps target to
the creator's binding, matching today's Phase F semantics where the
creator's goroutine owns the etag.
Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).
Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.
Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
351 lines
15 KiB
SQL
351 lines
15 KiB
SQL
-- 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-
|
|
-- <uuid>@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 <displayname/> 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-<uuid>@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 $$;
|