diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index bda6370..f13cdd0 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -404,10 +404,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 036 | `door_card_slots` (Door epic P3). Per-connection integer-slot allocation map (HeatSync keys cards by slot 0-200). UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id); lowest-free policy in pure logic. SELECT = door.manage/operate, service-client-only writes | | 037 | `classes.required_form_id` nullable FK -> forms(id) ON DELETE SET NULL. Optional per-class form gate (waiver-on-file): signup requires a completed form_submissions row; classes.manage override + on-behalf. App-enforced; no RLS change | | 038 | `space_visits` (presence/attendance) + `spaces.host_requires_card` bool default true. One open visit per member (partial unique); SELECT = any space member; service-client-only writes (self-resolved, immutable). No new permission code | +| 039 | DATA backfill only: link `form_submissions.member_id` (NULL) to earliest matching member by `(space_id, lower email = lower submitter_email)`. Idempotent; not in schema.sql | --- -## Tables added by migrations 016-038 (quick map) +## Tables added by migrations 016-039 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 8811be7..d63df4d 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -4,9 +4,9 @@ > **Database**: PostgreSQL via self-hosted Supabase > **Tables**: ~43 > **Source of Truth**: `scripts/schema.sql` (canonical, idempotent); numbered -> migrations `scripts/0NN_*.sql` (through **038**) upgrade existing +> migrations `scripts/0NN_*.sql` (through **039**) upgrade existing > deployments. The 13-table reference below is the original baseline; tables -> added by migrations 014-038 are summarized in the migrations section at the +> added by migrations 014-039 are summarized in the migrations section at the > end. `DB_SCHEMA_MAP.md` has the per-table quick map. --- @@ -687,7 +687,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at); --- -## Migrations 014-038 (additions since the 13-table baseline) +## Migrations 014-039 (additions since the 13-table baseline) `scripts/schema.sql` is the canonical idempotent schema; each numbered migration is mirrored as a section in it. Tables/columns added: @@ -719,6 +719,7 @@ migration is mirrored as a section in it. Tables/columns added: | 036 | `door_card_slots` (per-connection integer-slot allocation map for controllers that key cards by slot, e.g. HeatSync 0-200). `UNIQUE (connection_id, slot)` lets the DB arbitrate concurrent grants; `UNIQUE (connection_id, card_id)` makes re-grant idempotent. Allocation policy (lowest-free, range bounds) is pure unit-tested logic, not SQL, so it stays adapter-generic. RLS additive + default-deny: SELECT = `door.manage`/`door.operate`, NO write policy (validated service-client executor only, in lockstep with the controller). No new permission codes. No anonymous path | | 037 | `classes.required_form_id` nullable FK -> `forms(id)` ON DELETE SET NULL. A class can optionally require a form (waiver/intake); signup is hard-gated on the member having a completed `form_submissions` row for it (waiver-on-file), with a `classes.manage` override + sign-up-on-behalf, mirroring the equipment required-cert gate. Gate enforced in the app (pure logic + service-client boolean check); no RLS change (additive nullable column on the already-policied `classes`) | | 038 | `space_visits` (presence/attendance: check-in/out, `is_host`, in/out notes; partial UNIQUE `(space_id,member_id) WHERE checked_out_at IS NULL` = one open visit). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions; immutable history). No new permission code; org-wide attendance view is all-members by product decision. Also adds `spaces.host_requires_card` (bool, default true) gating host check-in, enforced in the app | +| 039 | DATA backfill only (no structural change): links existing `form_submissions.member_id` (where NULL) to the earliest-joined matching `space_members` by `(space_id, lower(email)=lower(submitter_email))`. Idempotent (NULL-only). Not mirrored in schema.sql (fresh DB has no submissions); ongoing linking is app-side (submitForm + linkSubmissionsByEmail) | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/scripts/039_link_submissions_by_email.sql b/scripts/039_link_submissions_by_email.sql new file mode 100644 index 0000000..7729c8d --- /dev/null +++ b/scripts/039_link_submissions_by_email.sql @@ -0,0 +1,35 @@ +-- ============================================================================= +-- 039: Backfill -- associate existing form submissions with members by email +-- ============================================================================= +-- One-time DATA backfill (no structural change; form_submissions.member_id +-- already exists). Links every unlinked submission to the member in the same +-- space whose email matches submitter_email (case-insensitive). When more +-- than one member shares an email in a space, the earliest-joined one is +-- chosen so the result is deterministic and matches the application logic +-- (lib/forms-logic.ts pickMemberForEmail). +-- +-- Idempotent: only rows with member_id IS NULL are touched, so re-running is +-- a no-op. Not mirrored in scripts/schema.sql (a fresh database has no +-- submissions to backfill); the ongoing linking happens in the app +-- (submitForm + linkSubmissionsByEmail on member create/email-change). +-- ============================================================================= + +UPDATE public.form_submissions fs +SET member_id = ( + SELECT m.id + FROM public.space_members m + WHERE m.space_id = fs.space_id + AND m.email IS NOT NULL + AND lower(m.email) = lower(fs.submitter_email) + ORDER BY m.joined_at ASC NULLS FIRST, m.id ASC + LIMIT 1 +) +WHERE fs.member_id IS NULL + AND fs.submitter_email IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.space_members m + WHERE m.space_id = fs.space_id + AND m.email IS NOT NULL + AND lower(m.email) = lower(fs.submitter_email) + );