From 4a1777de876fa22f87ec97dc40eeb8eb024592b1 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 10 Sep 2026 11:48:49 -0500 Subject: [PATCH] Keep legacy subscription tables as unmanaged instead of dropping them --- prisma.config.ts | 9 +++++++ .../migration.sql | 24 +++++++++++++++++++ prisma/schema.prisma | 6 +++++ src/lib/generated/prisma/internal/class.ts | 2 +- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260910160000_retain_legacy_subscription/migration.sql diff --git a/prisma.config.ts b/prisma.config.ts index 5a115ed..f854e50 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -5,6 +5,15 @@ import { defineConfig } from 'prisma/config'; export default defineConfig({ schema: 'prisma/schema.prisma', + // Legacy subscription tables are retained in the database but are no longer + // managed by Prisma Migrate. They must never be dropped (production may not + // own them and the data is kept for recovery). + experimental: { + externalTables: true + }, + tables: { + external: ['public.SubscriptionLevel', 'public.ProclamationCategorySubscriptionLevel'] + }, migrations: { path: 'prisma/migrations', seed: 'tsx prisma/seed.ts' diff --git a/prisma/migrations/20260910160000_retain_legacy_subscription/migration.sql b/prisma/migrations/20260910160000_retain_legacy_subscription/migration.sql new file mode 100644 index 0000000..26f01d4 --- /dev/null +++ b/prisma/migrations/20260910160000_retain_legacy_subscription/migration.sql @@ -0,0 +1,24 @@ +-- Retain the legacy subscription artifacts as externally managed data. +-- +-- SubscriptionLevel and ProclamationCategorySubscriptionLevel are no longer +-- modeled by the application. They are declared as external tables in +-- prisma.config.ts so Prisma Migrate ignores them entirely and never drops +-- them (production may not own these tables, and their data is kept for +-- recovery). +-- +-- This migration only touches Prisma-managed tables to decouple them from the +-- legacy FK. It is intentionally idempotent so it applies cleanly to +-- production (columns present, NOT NULL, FK present), a fresh database built +-- from migration history, and development databases that already removed the +-- legacy objects. + +-- Ensure the legacy columns exist. On databases built from migration history +-- these already exist; this re-adds them on environments that dropped them. +ALTER TABLE "Proclamation" ADD COLUMN IF NOT EXISTS "subscriptionLevelId" INTEGER; +ALTER TABLE "LicenseKey" ADD COLUMN IF NOT EXISTS "level" TEXT; + +-- Decouple from SubscriptionLevel: drop the FK and relax NOT NULL so the +-- application can keep inserting rows without a legacy subscription value. +ALTER TABLE "Proclamation" DROP CONSTRAINT IF EXISTS "Proclamation_subscriptionLevelId_fkey"; +ALTER TABLE "Proclamation" ALTER COLUMN "subscriptionLevelId" DROP NOT NULL; +ALTER TABLE "LicenseKey" ALTER COLUMN "level" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c259a4b..fb1c6a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,6 +130,9 @@ model Proclamation { groupId Int group ProclamationGroup @relation(fields: [groupId], references: [id]) userProclamations UserProclamations[] + // Legacy subscription-level FK. Retained in the database but unmanaged; the + // FK constraint is dropped and the value is left NULL. Hidden from the client. + subscriptionLevelId Int? @ignore } model ProclamationGroup { @@ -198,6 +201,9 @@ model LicenseKey { userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) email String? + // Legacy subscription level. Retained in the database but unmanaged; hidden + // from the client and left NULL. + level String? @ignore @@index([userId]) @@index([email]) diff --git a/src/lib/generated/prisma/internal/class.ts b/src/lib/generated/prisma/internal/class.ts index e0db5c7..f9f5fc1 100644 --- a/src/lib/generated/prisma/internal/class.ts +++ b/src/lib/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.8.0", "engineVersion": "3c6e192761c0362d496ed980de936e2f3cebcd3a", "activeProvider": "postgresql", - "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/lib/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n roleId Int? @default(4) // New users default to \"User\"\n role UserRole? @relation(fields: [roleId], references: [id])\n sessions Session[]\n accounts Account[]\n profiles Profile[]\n licenseKeys LicenseKey[]\n userProclamations UserProclamations[]\n\n @@unique([email])\n @@map(\"user\")\n}\n\n// I chose to create a Profile table here because honestly, I didn't want to waste\n// a bunch of time trying to figure out how to customize the default Better-Auth model. \nmodel Profile {\n id String @id @default(uuid())\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n email String?\n firstName String\n lastName String\n phoneNumber String\n ghlContactId String? @map(\"ghl_contact_id\")\n addresses Address[]\n\n @@index([userId])\n @@index([email])\n}\n\nmodel Address {\n id String @id @default(uuid())\n profileId String\n profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)\n street String\n city String\n state String\n postalCode String\n country String\n type String @default(\"Mailing\")\n isDefaultMailing Boolean @default(false)\n isDefaultBilling Boolean @default(false)\n isDefaultShipping Boolean @default(false)\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel UserRole {\n id Int @id @default(autoincrement())\n name String\n users User[]\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Proclamation {\n id String @id @default(uuid())\n name String @unique\n content String\n solfeggioFrequencyId Int\n solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])\n categoryId Int\n category ProclamationCategory @relation(fields: [categoryId], references: [id])\n groupId Int\n group ProclamationGroup @relation(fields: [groupId], references: [id])\n userProclamations UserProclamations[]\n}\n\nmodel ProclamationGroup {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\nmodel UserProclamations {\n userId String\n user User @relation(fields: [userId], references: [id])\n proclamationId String\n proclamation Proclamation @relation(fields: [proclamationId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@id([userId, proclamationId])\n}\n\nmodel SolfeggioFrequency {\n id Int @id @default(autoincrement())\n name String @unique\n value Int @unique\n proclamations Proclamation[]\n}\n\nmodel ProclamationCategory {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\n// Archive of the former child sub-categories removed when the\n// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.\n// `@@ignore` keeps it out of the generated client while Prisma remains aware\n// of the table so `migrate dev` does not report it as an untracked table.\nmodel ProclamationCategoryLegacy {\n id Int @id\n name String\n parentId Int?\n sortOrder Int @default(0)\n\n @@map(\"ProclamationCategoryLegacy\")\n @@ignore\n}\n\n// Archive of the category->subscription-level mappings for the child\n// categories removed when the category hierarchy was flattened. Kept with\n// `@@ignore` so Prisma remains aware of the table but it is excluded from the\n// generated client and Prisma's own migrations.\nmodel ProclamationCategorySubscriptionLevelLegacy {\n categoryId Int\n subscriptionLevelId Int\n\n @@id([categoryId, subscriptionLevelId])\n @@map(\"ProclamationCategorySubscriptionLevelLegacy\")\n @@ignore\n}\n\nmodel LicenseKey {\n id String @id @default(uuid())\n key String @unique\n active Boolean\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n email String?\n\n @@index([userId])\n @@index([email])\n}\n\nmodel ApiKey {\n id String @id @default(cuid())\n keyPrefix String\n keyHash String @unique\n createdAt DateTime @default(now())\n expiresAt DateTime?\n revokedAt DateTime?\n}\n\n// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).\n// `solfeggioFrequencyId` is the canonical id the kiosk uses (see\n// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.\nmodel ColorPalette {\n solfeggioFrequencyId Int @id\n colors Json // [\"#RRGGBB\", ... x7]\n\n @@map(\"ColorPalette\")\n}\n", + "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/lib/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n roleId Int? @default(4) // New users default to \"User\"\n role UserRole? @relation(fields: [roleId], references: [id])\n sessions Session[]\n accounts Account[]\n profiles Profile[]\n licenseKeys LicenseKey[]\n userProclamations UserProclamations[]\n\n @@unique([email])\n @@map(\"user\")\n}\n\n// I chose to create a Profile table here because honestly, I didn't want to waste\n// a bunch of time trying to figure out how to customize the default Better-Auth model. \nmodel Profile {\n id String @id @default(uuid())\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n email String?\n firstName String\n lastName String\n phoneNumber String\n ghlContactId String? @map(\"ghl_contact_id\")\n addresses Address[]\n\n @@index([userId])\n @@index([email])\n}\n\nmodel Address {\n id String @id @default(uuid())\n profileId String\n profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)\n street String\n city String\n state String\n postalCode String\n country String\n type String @default(\"Mailing\")\n isDefaultMailing Boolean @default(false)\n isDefaultBilling Boolean @default(false)\n isDefaultShipping Boolean @default(false)\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel UserRole {\n id Int @id @default(autoincrement())\n name String\n users User[]\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Proclamation {\n id String @id @default(uuid())\n name String @unique\n content String\n solfeggioFrequencyId Int\n solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])\n categoryId Int\n category ProclamationCategory @relation(fields: [categoryId], references: [id])\n groupId Int\n group ProclamationGroup @relation(fields: [groupId], references: [id])\n userProclamations UserProclamations[]\n // Legacy subscription-level FK. Retained in the database but unmanaged; the\n // FK constraint is dropped and the value is left NULL. Hidden from the client.\n subscriptionLevelId Int? @ignore\n}\n\nmodel ProclamationGroup {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\nmodel UserProclamations {\n userId String\n user User @relation(fields: [userId], references: [id])\n proclamationId String\n proclamation Proclamation @relation(fields: [proclamationId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@id([userId, proclamationId])\n}\n\nmodel SolfeggioFrequency {\n id Int @id @default(autoincrement())\n name String @unique\n value Int @unique\n proclamations Proclamation[]\n}\n\nmodel ProclamationCategory {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\n// Archive of the former child sub-categories removed when the\n// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.\n// `@@ignore` keeps it out of the generated client while Prisma remains aware\n// of the table so `migrate dev` does not report it as an untracked table.\nmodel ProclamationCategoryLegacy {\n id Int @id\n name String\n parentId Int?\n sortOrder Int @default(0)\n\n @@map(\"ProclamationCategoryLegacy\")\n @@ignore\n}\n\n// Archive of the category->subscription-level mappings for the child\n// categories removed when the category hierarchy was flattened. Kept with\n// `@@ignore` so Prisma remains aware of the table but it is excluded from the\n// generated client and Prisma's own migrations.\nmodel ProclamationCategorySubscriptionLevelLegacy {\n categoryId Int\n subscriptionLevelId Int\n\n @@id([categoryId, subscriptionLevelId])\n @@map(\"ProclamationCategorySubscriptionLevelLegacy\")\n @@ignore\n}\n\nmodel LicenseKey {\n id String @id @default(uuid())\n key String @unique\n active Boolean\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n email String?\n // Legacy subscription level. Retained in the database but unmanaged; hidden\n // from the client and left NULL.\n level String? @ignore\n\n @@index([userId])\n @@index([email])\n}\n\nmodel ApiKey {\n id String @id @default(cuid())\n keyPrefix String\n keyHash String @unique\n createdAt DateTime @default(now())\n expiresAt DateTime?\n revokedAt DateTime?\n}\n\n// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).\n// `solfeggioFrequencyId` is the canonical id the kiosk uses (see\n// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.\nmodel ColorPalette {\n solfeggioFrequencyId Int @id\n colors Json // [\"#RRGGBB\", ... x7]\n\n @@map(\"ColorPalette\")\n}\n", "runtimeDataModel": { "models": {}, "enums": {},