Rebuild admin pages around DataTable/AdminModal; restructure categories/groups and v2 groups API
Remove the generic $lib/svelte-admin CRUD layer and AdminTable.svelte; each admin route now owns its own form/modal logic. Add DataTable.svelte, superform-errors.ts, and profile-specific modal/field components. Flatten proclamation categories/groups and drop subscription levels; add the v2 groups API; update v1/v2 endpoints, serializers, auth, and regenerated Prisma client with the new migrations. Document the overhaul plan in docs/ADMIN_OVERHAUL.md.
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
||||
-- Flatten ProclamationCategory hierarchy into a flat list and introduce
|
||||
-- ProclamationGroup (formerly child sub-categories).
|
||||
--
|
||||
-- This migration is intentionally NON-DESTRUCTIVE:
|
||||
-- * ProclamationGroup / Proclamation.groupId are added additively.
|
||||
-- * Former child categories are ARCHIVED to ProclamationCategoryLegacy
|
||||
-- before the hierarchy (parentId) is removed, so no category data is lost.
|
||||
-- * Subscription-level resources (SubscriptionLevel,
|
||||
-- ProclamationCategorySubscriptionLevel, Proclamation.subscriptionLevelId,
|
||||
-- LicenseKey.level) are LEFT IN PLACE as unmanaged legacy data. They are no
|
||||
-- longer modeled by the Prisma schema and are ignored by the application,
|
||||
-- but their data is preserved and no DROP is attempted on tables the deploy
|
||||
-- role may not own.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProclamationGroup" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProclamationGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProclamationGroup_name_key" ON "ProclamationGroup"("name");
|
||||
|
||||
-- Seed groups from every distinct former sub-category name
|
||||
INSERT INTO "ProclamationGroup" ("name", "sortOrder")
|
||||
SELECT "name",
|
||||
ROW_NUMBER() OVER (ORDER BY "name") - 1
|
||||
FROM (
|
||||
SELECT DISTINCT "name" FROM "ProclamationCategory" WHERE "parentId" IS NOT NULL
|
||||
) AS d;
|
||||
|
||||
-- AddColumn (nullable during backfill)
|
||||
ALTER TABLE "Proclamation" ADD COLUMN "groupId" INTEGER;
|
||||
|
||||
-- Backfill: former child categories become the proclamation's group; the
|
||||
-- parent becomes its flat category.
|
||||
UPDATE "Proclamation" AS p
|
||||
SET "groupId" = g."id",
|
||||
"categoryId" = c."parentId"
|
||||
FROM "ProclamationCategory" AS c
|
||||
JOIN "ProclamationGroup" AS g ON g."name" = c."name"
|
||||
WHERE p."categoryId" = c."id" AND c."parentId" IS NOT NULL;
|
||||
|
||||
-- Backfill: proclamations that had no group (e.g. 'Custom (Client Specific)')
|
||||
-- default to the General group, if it exists.
|
||||
UPDATE "Proclamation"
|
||||
SET "groupId" = (SELECT "id" FROM "ProclamationGroup" WHERE "name" = 'General')
|
||||
WHERE "groupId" IS NULL;
|
||||
|
||||
-- Safety net: any proclamation still unmapped takes the first group.
|
||||
UPDATE "Proclamation"
|
||||
SET "groupId" = (SELECT "id" FROM "ProclamationGroup" ORDER BY "sortOrder" LIMIT 1)
|
||||
WHERE "groupId" IS NULL;
|
||||
|
||||
ALTER TABLE "Proclamation" ALTER COLUMN "groupId" SET NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Proclamation" ADD CONSTRAINT "Proclamation_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProclamationGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Archive former child categories before removing them, so their data is
|
||||
-- preserved and recoverable. The legacy table also records each child's
|
||||
-- parent for audit/recovery.
|
||||
CREATE TABLE "ProclamationCategoryLegacy" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parentId" INTEGER,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProclamationCategoryLegacy_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "ProclamationCategoryLegacy" ("id", "name", "parentId", "sortOrder")
|
||||
SELECT "id", "name", "parentId", "sortOrder"
|
||||
FROM "ProclamationCategory"
|
||||
WHERE "parentId" IS NOT NULL;
|
||||
|
||||
-- Archive category->subscription mappings for the child categories being
|
||||
-- removed, so the cascade delete below does not destroy this (deprecated)
|
||||
-- mapping data. Requires read access to the join table, but never ownership.
|
||||
CREATE TABLE "ProclamationCategorySubscriptionLevelLegacy" (
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"subscriptionLevelId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ProclamationCategorySubscriptionLevelLegacy_pkey" PRIMARY KEY ("categoryId", "subscriptionLevelId")
|
||||
);
|
||||
|
||||
INSERT INTO "ProclamationCategorySubscriptionLevelLegacy" ("categoryId", "subscriptionLevelId")
|
||||
SELECT j."categoryId", j."subscriptionLevelId"
|
||||
FROM "ProclamationCategorySubscriptionLevel" AS j
|
||||
JOIN "ProclamationCategory" AS c ON c."id" = j."categoryId" AND c."parentId" IS NOT NULL;
|
||||
|
||||
-- Remove former child categories (their data is archived above; the
|
||||
-- subscription-level join rows for them are archived above and the
|
||||
-- SubscriptionLevel table itself is left untouched).
|
||||
DELETE FROM "ProclamationCategory" WHERE "parentId" IS NOT NULL;
|
||||
|
||||
-- Drop self-relation
|
||||
ALTER TABLE "ProclamationCategory" DROP CONSTRAINT "ProclamationCategory_parentId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "ProclamationCategory_parentId_name_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProclamationCategory" DROP COLUMN "parentId";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProclamationCategory_name_key" ON "ProclamationCategory"("name");
|
||||
|
||||
-- Subscription-level resources are intentionally left in place (unmanaged).
|
||||
-- No DROP TABLE / DROP COLUMN / DROP CONSTRAINT is performed here, so this
|
||||
-- migration never requires ownership of pre-existing subscription tables and
|
||||
-- never destroys subscription data.
|
||||
@@ -0,0 +1,28 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LicenseKey" DROP CONSTRAINT "LicenseKey_userId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Profile_userId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LicenseKey" ADD COLUMN "email" TEXT,
|
||||
ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Profile" ADD COLUMN "email" TEXT,
|
||||
ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LicenseKey_userId_idx" ON "LicenseKey"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LicenseKey_email_idx" ON "LicenseKey"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Profile_userId_idx" ON "Profile"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Profile_email_idx" ON "Profile"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LicenseKey" ADD CONSTRAINT "LicenseKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+62
-45
@@ -25,7 +25,7 @@ model User {
|
||||
role UserRole? @relation(fields: [roleId], references: [id])
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
profile Profile?
|
||||
profiles Profile[]
|
||||
licenseKeys LicenseKey[]
|
||||
userProclamations UserProclamations[]
|
||||
|
||||
@@ -37,28 +37,32 @@ model User {
|
||||
// a bunch of time trying to figure out how to customize the default Better-Auth model.
|
||||
model Profile {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
email String?
|
||||
firstName String
|
||||
lastName String
|
||||
phoneNumber String
|
||||
ghlContactId String? @map("ghl_contact_id")
|
||||
addresses Address[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model Address {
|
||||
id String @id @default(uuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
street String
|
||||
city String
|
||||
state String
|
||||
postalCode String
|
||||
country String
|
||||
type String @default("Mailing")
|
||||
isDefaultMailing Boolean @default(false)
|
||||
isDefaultBilling Boolean @default(false)
|
||||
isDefaultShipping Boolean @default(false)
|
||||
id String @id @default(uuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
street String
|
||||
city String
|
||||
state String
|
||||
postalCode String
|
||||
country String
|
||||
type String @default("Mailing")
|
||||
isDefaultMailing Boolean @default(false)
|
||||
isDefaultBilling Boolean @default(false)
|
||||
isDefaultShipping Boolean @default(false)
|
||||
}
|
||||
|
||||
model Session {
|
||||
@@ -123,9 +127,16 @@ model Proclamation {
|
||||
solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])
|
||||
categoryId Int
|
||||
category ProclamationCategory @relation(fields: [categoryId], references: [id])
|
||||
groupId Int
|
||||
group ProclamationGroup @relation(fields: [groupId], references: [id])
|
||||
userProclamations UserProclamations[]
|
||||
subscriptionLevelId Int // Keeping old FK for migration stability
|
||||
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id])
|
||||
}
|
||||
|
||||
model ProclamationGroup {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
proclamations Proclamation[]
|
||||
}
|
||||
|
||||
model UserProclamations {
|
||||
@@ -142,40 +153,54 @@ model UserProclamations {
|
||||
model SolfeggioFrequency {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
value Int @unique
|
||||
value Int @unique
|
||||
proclamations Proclamation[]
|
||||
}
|
||||
|
||||
model ProclamationCategory {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sortOrder Int @default(0)
|
||||
parentId Int?
|
||||
parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||
children ProclamationCategory[] @relation("CategoryHierarchy")
|
||||
proclamations Proclamation[]
|
||||
subscriptionLevels ProclamationCategorySubscriptionLevel[]
|
||||
|
||||
@@unique([parentId, name])
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
proclamations Proclamation[]
|
||||
}
|
||||
|
||||
// Join table for ProclamationCategory -> SubscriptionLevel defaults
|
||||
model ProclamationCategorySubscriptionLevel {
|
||||
categoryId Int
|
||||
category ProclamationCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
// Archive of the former child sub-categories removed when the
|
||||
// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.
|
||||
// `@@ignore` keeps it out of the generated client while Prisma remains aware
|
||||
// of the table so `migrate dev` does not report it as an untracked table.
|
||||
model ProclamationCategoryLegacy {
|
||||
id Int @id
|
||||
name String
|
||||
parentId Int?
|
||||
sortOrder Int @default(0)
|
||||
|
||||
@@map("ProclamationCategoryLegacy")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
// Archive of the category->subscription-level mappings for the child
|
||||
// categories removed when the category hierarchy was flattened. Kept with
|
||||
// `@@ignore` so Prisma remains aware of the table but it is excluded from the
|
||||
// generated client and Prisma's own migrations.
|
||||
model ProclamationCategorySubscriptionLevelLegacy {
|
||||
categoryId Int
|
||||
subscriptionLevelId Int
|
||||
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([categoryId, subscriptionLevelId])
|
||||
@@map("ProclamationCategorySubscriptionLevelLegacy")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model LicenseKey {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
active Boolean
|
||||
level String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
email String?
|
||||
|
||||
@@index([userId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
@@ -187,14 +212,6 @@ model ApiKey {
|
||||
revokedAt DateTime?
|
||||
}
|
||||
|
||||
model SubscriptionLevel {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
proclamations Proclamation[]
|
||||
categoryLevels ProclamationCategorySubscriptionLevel[]
|
||||
}
|
||||
|
||||
// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).
|
||||
// `solfeggioFrequencyId` is the canonical id the kiosk uses (see
|
||||
// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.
|
||||
@@ -203,4 +220,4 @@ model ColorPalette {
|
||||
colors Json // ["#RRGGBB", ... x7]
|
||||
|
||||
@@map("ColorPalette")
|
||||
}
|
||||
}
|
||||
|
||||
+26
-43
@@ -72,43 +72,26 @@ const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const subscriptionLevels = [
|
||||
{ id: 1, name: 'Basic', description: 'General only' },
|
||||
{ id: 2, name: 'Advanced', description: 'General + Specific' },
|
||||
{ id: 3, name: 'Elite', description: 'General + Specific + Precise' }
|
||||
const proclamationGroups = [
|
||||
{ name: 'General', sortOrder: 0 },
|
||||
{ name: 'Specific', sortOrder: 1 },
|
||||
{ name: 'Precise', sortOrder: 2 },
|
||||
{ name: 'Prayers', sortOrder: 3 },
|
||||
{ name: 'Scripture', sortOrder: 4 }
|
||||
];
|
||||
|
||||
// Flat category list (formerly hierarchical parents).
|
||||
const proclamationCategories = [
|
||||
{ id: 4, name: 'Spiritual', parentId: null },
|
||||
{ id: 5, name: 'General', parentId: 4 },
|
||||
{ id: 9, name: 'Specific', parentId: 4 },
|
||||
{ id: 40, name: 'Precise', parentId: 4 },
|
||||
{ id: 2, name: 'Physical', parentId: null },
|
||||
{ id: 3, name: 'General', parentId: 2 },
|
||||
{ id: 10, name: 'Specific', parentId: 2 },
|
||||
{ id: 32, name: 'Precise', parentId: 2 },
|
||||
{ id: 16, name: 'Mental', parentId: null },
|
||||
{ id: 30, name: 'General', parentId: 16 },
|
||||
{ id: 31, name: 'Specific', parentId: 16 },
|
||||
{ id: 38, name: 'Precise', parentId: 16 },
|
||||
{ id: 17, name: 'Custom (Client Specific)', parentId: null },
|
||||
{ id: 19, name: 'Financial', parentId: null },
|
||||
{ id: 28, name: 'General', parentId: 19 },
|
||||
{ id: 29, name: 'Specific', parentId: 19 },
|
||||
{ id: 37, name: 'Precise', parentId: 19 },
|
||||
{ id: 20, name: 'Emotional', parentId: null },
|
||||
{ id: 24, name: 'General', parentId: 20 },
|
||||
{ id: 26, name: 'Specific', parentId: 20 },
|
||||
{ id: 45, name: 'Precise', parentId: 20 },
|
||||
{ id: 21, name: 'Social', parentId: null },
|
||||
{ id: 22, name: 'General', parentId: 21 },
|
||||
{ id: 23, name: 'Specific', parentId: 21 },
|
||||
{ id: 39, name: 'Precise', parentId: 21 },
|
||||
{ id: 41, name: 'Biblical', parentId: null },
|
||||
{ id: 42, name: 'Personal Growth & Development', parentId: null },
|
||||
{ id: 43, name: 'Prayers', parentId: 41 },
|
||||
{ id: 44, name: 'Scripture', parentId: 41 },
|
||||
{ id: 46, name: 'Sports', parentId: null }
|
||||
{ id: 4, name: 'Spiritual' },
|
||||
{ id: 2, name: 'Physical' },
|
||||
{ id: 16, name: 'Mental' },
|
||||
{ id: 17, name: 'Custom (Client Specific)' },
|
||||
{ id: 19, name: 'Financial' },
|
||||
{ id: 20, name: 'Emotional' },
|
||||
{ id: 21, name: 'Social' },
|
||||
{ id: 41, name: 'Biblical' },
|
||||
{ id: 42, name: 'Personal Growth & Development' },
|
||||
{ id: 46, name: 'Sports' }
|
||||
];
|
||||
|
||||
async function main() {
|
||||
@@ -130,6 +113,15 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeding ProclamationGroup...');
|
||||
for (const group of proclamationGroups) {
|
||||
await prisma.proclamationGroup.upsert({
|
||||
where: { name: group.name },
|
||||
create: group,
|
||||
update: group
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeding ProclamationCategory...');
|
||||
for (const cat of proclamationCategories) {
|
||||
await prisma.proclamationCategory.upsert({
|
||||
@@ -139,15 +131,6 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeding SubscriptionLevel...');
|
||||
for (const level of subscriptionLevels) {
|
||||
await prisma.subscriptionLevel.upsert({
|
||||
where: { id: level.id },
|
||||
create: level,
|
||||
update: level
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeding ColorPalette...');
|
||||
for (const palette of colorPalettes) {
|
||||
await prisma.colorPalette.upsert({
|
||||
|
||||
Reference in New Issue
Block a user