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:
NeilClack
2026-09-10 09:38:01 -05:00
parent f4e736c92d
commit 91d162c746
127 changed files with 7182 additions and 9785 deletions
+22 -47
View File
@@ -5,9 +5,9 @@ import {
canonicalIdForFrequency,
serializeCategory,
serializeColorPalette,
serializeGroup,
serializeLicense,
serializeProclamation,
serializeSubscriptionLevel
serializeProclamation
} from './v2-serializers';
describe('canonicalIdForFrequency', () => {
@@ -61,7 +61,7 @@ describe('serializeProclamation', () => {
name: 'Peace & Relaxation',
content: '<p>The text content, HTML formatted</p>',
categoryId: 5,
subscriptionLevelId: 1
groupId: 1
};
it('derives solfeggioFrequencyId from the Hz value, not the DB id', () => {
@@ -84,13 +84,13 @@ describe('serializeProclamation', () => {
expect(record).toBeNull();
});
it('passes through a null subscriptionLevelId (kiosk treats as level 1)', () => {
it('passes through categoryId and groupId unchanged', () => {
const record = serializeProclamation({
...base,
subscriptionLevelId: null,
solfeggioFrequency: solarPlexus528
});
expect(record?.subscriptionLevelId).toBeNull();
expect(record?.categoryId).toBe(5);
expect(record?.groupId).toBe(1);
});
it('embeds a 7-color palette when provided', () => {
@@ -116,64 +116,39 @@ describe('serializeProclamation', () => {
});
describe('serializeCategory', () => {
it('keeps parentId null for root categories', () => {
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0, parentId: null })).toEqual({
it('maps rows to a flat {id, name, sortOrder} with no parentId', () => {
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0 })).toEqual({
id: 4,
name: 'Spiritual',
sortOrder: 0,
parentId: null
sortOrder: 0
});
expect(
Object.keys(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0 }))
).not.toContain('parentId');
});
});
it('keeps parentId int for children', () => {
expect(serializeCategory({ id: 5, name: 'General', sortOrder: 0, parentId: 4 })).toEqual({
id: 5,
describe('serializeGroup', () => {
it('maps rows to {id, name, sortOrder}', () => {
expect(serializeGroup({ id: 1, name: 'General', sortOrder: 0 })).toEqual({
id: 1,
name: 'General',
sortOrder: 0,
parentId: 4
sortOrder: 0
});
});
});
describe('serializeLicense', () => {
it('returns subscription_level as an object when the level row exists', () => {
it('returns only key and email_address (levels removed)', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'Advanced',
level: { id: 2, name: 'Advanced', description: 'General + Specific' }
emailAddress: 'user@example.com'
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent object when no level row matches', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'SomeCustomLevel',
level: null
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
});
});
});
describe('serializeSubscriptionLevel', () => {
it('maps rows to {id, name, description}', () => {
expect(
serializeSubscriptionLevel({ id: 1, name: 'Basic', description: 'General only' })
).toEqual({
id: 1,
name: 'Basic',
description: 'General only'
email_address: 'user@example.com'
});
expect(Object.keys(result)).not.toContain('subscription_level');
});
});
+14 -35
View File
@@ -55,8 +55,8 @@ export type V2Proclamation = {
name: string;
content: string;
categoryId: number;
groupId: number;
solfeggioFrequencyId: number;
subscriptionLevelId: number | null;
colorPalette?: string[];
};
@@ -65,7 +65,7 @@ export type ProclamationWithFrequency = {
name: string;
content: string;
categoryId: number;
subscriptionLevelId: number | null;
groupId: number;
solfeggioFrequency: { value: number };
};
@@ -83,8 +83,8 @@ export function serializeProclamation(
name: p.name,
content: p.content,
categoryId: p.categoryId,
solfeggioFrequencyId,
subscriptionLevelId: p.subscriptionLevelId
groupId: p.groupId,
solfeggioFrequencyId
};
if (palette && palette.length > 0) {
@@ -98,53 +98,32 @@ export type V2Category = {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
};
export function serializeCategory(c: {
export function serializeCategory(c: { id: number; name: string; sortOrder: number }): V2Category {
return { id: c.id, name: c.name, sortOrder: c.sortOrder };
}
export type V2Group = {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
}): V2Category {
return { id: c.id, name: c.name, sortOrder: c.sortOrder, parentId: c.parentId };
}
export type V2SubscriptionLevel = {
id: number;
name: string;
description: string | null;
};
export function serializeSubscriptionLevel(level: {
id: number;
name: string;
description: string | null;
}): V2SubscriptionLevel {
return { id: level.id, name: level.name, description: level.description };
export function serializeGroup(g: { id: number; name: string; sortOrder: number }): V2Group {
return { id: g.id, name: g.name, sortOrder: g.sortOrder };
}
export type V2License = {
key: string;
email_address: string;
subscription_level: { id: number | null; name: string; description: string | null };
};
/** Build the POST /license response. `subscription_level` is always an object;
* when no SubscriptionLevel row matches the license's level name, fall back to
* a structurally consistent object with a null id/description. */
export function serializeLicense(opts: {
key: string;
emailAddress: string;
levelName: string;
level: { id: number; name: string; description: string | null } | null;
}): V2License {
/** Build the POST /license response. */
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License {
return {
key: opts.key,
email_address: opts.emailAddress,
subscription_level: opts.level
? { id: opts.level.id, name: opts.level.name, description: opts.level.description }
: { id: null, name: opts.levelName, description: null }
email_address: opts.emailAddress
};
}
+1 -1
View File
@@ -17,7 +17,7 @@ function signedRequest(opts: {
const path = '/api/v2/automations/create/account';
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
const body = '{"email":"user@example.com","subscription_level":"Basic"}';
const body = '{"email":"user@example.com"}';
let ts = timestamp;
let nonceValue = nonce;