Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Read these before changing the relevant area — start here, then follow links.
- `CUJS.md` — **critical user journeys** (the basis for flow tests).
- `DEV_INSTANCE_DESIGN.md` — the `CHECKIN_ENV` prod/dev/local model + persona-mint/impersonation (read before touching auth/env).
- `DEV_DASHBOARD_DESIGN.md` — dev dashboard + seed/reset macros.
- `PROGRAM_INSTANCE_RESTRUCTURE.md` — the Program → ProgramInstance → Event 3-tier split (phased; Phase 1 = additive schema + backfill, later phases deferred). Read before touching the Program/Event tier or its FKs.
- `PRODUCTION_PLAN.md`, `implementation_plan.md`, `MY_PROGRAMS_SCOPING.md`, `ARCHITECT_IDEAS_*.md` — roadmap/scoping notes.

**Security** (`docs/security/`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
-- Program → Instance → Event restructure, PHASE 1 (additive/expand only).
-- See docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md (§7 P1) + its re-validation
-- section. This migration ONLY adds: the ProgramInstance table, a nullable
-- Event.instanceId FK, and a 1:1 backfill. It moves NO child FK, drops NO
-- column, renames nothing — every destructive/rename step (P2–P5) is a later
-- release per DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md (expand-contract).
--
-- Safe for the LIVE DB during the deploy drain window: old code neither reads
-- nor writes the new table/column, and nothing it does read (Program.*,
-- Event.programId, child *.programId) is touched. The backfill only populates
-- new rows/columns.
--
-- prisma migrate deploy does NOT wrap a migration file in a transaction on
-- Postgres (prisma/prisma#15295), and the DDL + backfill must all land or none
-- (a half-applied backfill would leave the sequence unbumped) — so wrap
-- explicitly (DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md rule 5).
BEGIN;

-- ── DDL (generated by `prisma migrate diff`, verbatim) ──────────────────────

-- AlterTable
ALTER TABLE "Event" ADD COLUMN "instanceId" INTEGER;

-- CreateTable
CREATE TABLE "ProgramInstance" (
"id" SERIAL NOT NULL,
"programId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"leadMentorId" INTEGER,
"startAt" TIMESTAMP(3),
"endAt" TIMESTAMP(3),
"phase" "ProgramPhase" NOT NULL DEFAULT 'PLANNING',
"enrollmentStatus" "EnrollmentStatus" NOT NULL DEFAULT 'CLOSED',
"maxParticipants" INTEGER,
"leadMentorNotificationSettings" JSONB,
"minAge" INTEGER,
"maxAge" INTEGER,
"orgMemberOnly" BOOLEAN,
"shopifyProductId" TEXT,
"shopifyVariantId" TEXT,
"shopifyOrgMemberVariantId" TEXT,
"shopifyNonOrgMemberVariantId" TEXT,

CONSTRAINT "ProgramInstance_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "ProgramInstance" ADD CONSTRAINT "ProgramInstance_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProgramInstance" ADD CONSTRAINT "ProgramInstance_leadMentorId_fkey" FOREIGN KEY ("leadMentorId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "ProgramInstance"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- ── Backfill (MB) ───────────────────────────────────────────────────────────
-- Idempotent (re-runnable if a deploy retries): the INSERT skips programs that
-- already have an instance, the UPDATE only fills still-null instanceIds, and
-- setval is derived from MAX(id) so it never regresses.
-- programInstanceBackfill.integration.test.ts extracts everything between the two
-- bare BACKFILL markers below and runs it twice — keep the markers exact.
-- >>> BACKFILL START

-- One ProgramInstance per existing Program, id-aliased (instance.id =
-- program.id). The alias is what lets the later FK repoint (P2) be a pure
-- RENAME with no value rewrite: every child *.programId already equals the
-- instance id it will point at. Offering columns are copied; the narrowing
-- overrides (minAge/maxAge/orgMemberOnly) stay NULL = "inherit the definition".
INSERT INTO "ProgramInstance" (
"id", "programId", "name", "leadMentorId", "startAt", "endAt", "phase",
"enrollmentStatus", "maxParticipants", "leadMentorNotificationSettings",
"shopifyProductId", "shopifyVariantId", "shopifyOrgMemberVariantId",
"shopifyNonOrgMemberVariantId"
)
SELECT
p."id", p."id", p."name", p."leadMentorId", p."startAt", p."endAt", p."phase",
p."enrollmentStatus", p."maxParticipants", p."leadMentorNotificationSettings",
p."shopifyProductId", p."shopifyVariantId", p."shopifyOrgMemberVariantId",
p."shopifyNonOrgMemberVariantId"
FROM "Program" p
WHERE NOT EXISTS (SELECT 1 FROM "ProgramInstance" i WHERE i."id" = p."id");

-- Point existing program-bound events at their program's (id-aliased) instance.
-- Program-less events (programId NULL) stay unlinked, as before.
UPDATE "Event" e
SET "instanceId" = e."programId"
WHERE e."programId" IS NOT NULL AND e."instanceId" IS NULL;

-- Bump the id sequence past the aliased ids, or the next autoincrement insert
-- collides with a backfilled row. Derived from MAX(id) (self-correcting: safe on
-- an empty table → next id 1, and never moves the sequence backward on re-run).
SELECT setval(
pg_get_serial_sequence('"ProgramInstance"', 'id'),
(SELECT COALESCE(MAX("id"), 0) FROM "ProgramInstance") + 1,
false
);
-- >>> BACKFILL END

COMMIT;
Loading
Loading