feat(forms): migration 039 backfill existing submissions to members by email

Data-only, idempotent (NULL member_id only): links each prior unlinked
form_submission to the earliest-joined matching space_member by
(space_id, lower(email)=lower(submitter_email)). Not mirrored in
schema.sql (fresh DB has no submissions). Documented in
DATABASE_SCHEMA + DB_SCHEMA_MAP (migration range bumped to 039).
This commit is contained in:
Moheeb Zara
2026-05-16 23:46:56 -07:00
parent dbed9ac1bd
commit 832a0aeba2
3 changed files with 41 additions and 4 deletions
+2 -1
View File
@@ -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 |
|-------|-------------|---------|
+4 -3
View File
@@ -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`.
+35
View File
@@ -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)
);