diff --git a/src/lib/svelte-admin/crud.spec.ts b/src/lib/svelte-admin/crud.spec.ts index 791d87d..f2b4a78 100644 --- a/src/lib/svelte-admin/crud.spec.ts +++ b/src/lib/svelte-admin/crud.spec.ts @@ -6,38 +6,44 @@ import { getModelConfig } from './registry'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; -vi.mock('$lib/db', () => ({ - prisma: { - userRole: { - findMany: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - findUnique: vi.fn(), - findFirst: vi.fn() +vi.mock('$lib/db', () => { + const mkdel = () => ({ + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn() + }); + return { + prisma: { + userRole: mkdel(), + proclamation: mkdel(), + proclamationCategory: mkdel(), + solfeggioFrequency: mkdel(), + subscriptionLevel: mkdel() } - } -})); + }; +}); import { prisma } from '$lib/db'; import { loadRecords, upsertRecord, deleteRecord } from './crud'; -const findMany = prisma.userRole.findMany as ReturnType; +const userRoleFindMany = prisma.userRole.findMany as ReturnType; const create = prisma.userRole.create as ReturnType; const update = prisma.userRole.update as ReturnType; const deletes = prisma.userRole.delete as ReturnType; const findUnique = prisma.userRole.findUnique as ReturnType; const findFirst = prisma.userRole.findFirst as ReturnType; - const config = getModelConfig('user-roles')!; describe('crud loadRecords', () => { beforeEach(() => { - findMany.mockReset(); + userRoleFindMany.mockReset(); }); it('returns records', async () => { - findMany.mockResolvedValue([{ id: 1, name: 'Admin' }]); + userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]); const locals = createLocals(mockUser() as any); const result = await loadRecords(locals, config); @@ -46,7 +52,7 @@ describe('crud loadRecords', () => { }); it('returns empty records array', async () => { - findMany.mockResolvedValue([]); + userRoleFindMany.mockResolvedValue([]); const locals = createLocals(mockUser() as any); const result = await loadRecords(locals, config); @@ -127,6 +133,64 @@ describe('crud deleteRecord', () => { }); }); +describe('P2002 unique constraint handling', () => { + beforeEach(() => { + create.mockReset(); + }); + + it('returns error with field names when meta.target is populated', async () => { + const p2002 = new Error('Unique constraint failed'); + (p2002 as any).code = 'P2002'; + (p2002 as any).meta = { target: ['name'], modelName: 'UserRole' }; + create.mockRejectedValue(p2002); + + const fd = new FormData(); + fd.set('name', 'Admin'); + const locals = createLocals(mockUser() as any); + const result = await upsertRecord(fd, config, locals); + + expect(result).toEqual({ + success: false, + error: 'A record with this name already exists.', + fieldErrors: { name: 'Already taken' } + }); + }); + + it('returns generic message when meta.target is empty', async () => { + const p2002 = new Error('Unique constraint failed'); + (p2002 as any).code = 'P2002'; + (p2002 as any).meta = { target: [], modelName: 'UserRole' }; + create.mockRejectedValue(p2002); + + const fd = new FormData(); + fd.set('name', 'Admin'); + const locals = createLocals(mockUser() as any); + const result = await upsertRecord(fd, config, locals); + + expect(result).toEqual({ + success: false, + error: 'A database constraint violation occurred. The record could not be created.' + }); + }); + + it('returns generic message when meta.target is missing', async () => { + const p2002 = new Error('Unique constraint failed'); + (p2002 as any).code = 'P2002'; + (p2002 as any).meta = { modelName: 'UserRole' }; + create.mockRejectedValue(p2002); + + const fd = new FormData(); + fd.set('name', 'Admin'); + const locals = createLocals(mockUser() as any); + const result = await upsertRecord(fd, config, locals); + + expect(result).toEqual({ + success: false, + error: 'A database constraint violation occurred. The record could not be created.' + }); + }); +}); + describe('sveltekit-superforms import compatibility', () => { it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => { expect(superValidate).toBeDefined(); @@ -151,17 +215,11 @@ describe('prisma client version consistency', () => { it('generated client references existing query compiler files', async () => { const fs = await import('fs'); const path = await import('path'); - const runtimeDir = path.resolve( - 'node_modules/@prisma/client/runtime' - ); + const runtimeDir = path.resolve('node_modules/@prisma/client/runtime'); - const generatedFile = path.resolve( - 'src/lib/generated/prisma/internal/class.ts' - ); + const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts'); const content = fs.readFileSync(generatedFile, 'utf-8'); - const compilerMatch = content.match( - /import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/ - ); + const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/); expect(compilerMatch).not.toBeNull(); const compilerPath = path.join(runtimeDir, compilerMatch![1]); expect(fs.existsSync(compilerPath)).toBe(true); diff --git a/src/lib/svelte-admin/crud.ts b/src/lib/svelte-admin/crud.ts index 948aaa2..cc0a922 100644 --- a/src/lib/svelte-admin/crud.ts +++ b/src/lib/svelte-admin/crud.ts @@ -52,8 +52,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig) }); const relationOptionsData: Record = {}; - // For cascade child fields: maps field -> { childValue -> parentValue } - const cascadeOptionParentMap: Record> = {}; for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) { const relDelegate = getDelegate({ ...config, modelName: relConfig.model }); @@ -61,13 +59,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig) [relConfig.valueField]: true, [relConfig.labelField]: true }; - // Check if this field is a cascade child (needs parentId) - const cascadeEntry = Object.entries(config.cascadeFields ?? {}).find( - ([, c]) => c.parentField === field - ); - if (cascadeEntry) { - selectFields['parentId'] = true; - } const options = await relDelegate.findMany({ select: selectFields, orderBy: { [relConfig.labelField]: 'asc' } @@ -77,27 +68,11 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig) value: opt[relConfig.valueField] as string | number })); relationOptionsData[field] = mapped; - - // Build parent map if cascade child - if (cascadeEntry) { - const childField = cascadeEntry[0]; - const parentMap: Record = {}; - const rows = options as Record[]; - for (const row of rows) { - const childVal = row[relConfig.valueField] as string | number; - const parentVal = row['parentId'] as number | null; - if (parentVal != null) { - parentMap[childVal] = parentVal; - } - } - cascadeOptionParentMap[childField] = parentMap; - } } return { records: records as Record[], - relationOptionsData, - cascadeOptionParentMap + relationOptionsData }; } @@ -106,8 +81,11 @@ export async function upsertRecord( config: AdminModelConfig, locals: App.Locals ) { + const formData = + requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData(); + const schema = modelSchemas[config.modelName]; - const form = schema ? await superValidate(requestOrFormData, zod4(schema)) : null; + const form = schema ? await superValidate(formData, zod4(schema)) : null; if (form && !form.valid) { const { error, fieldErrors } = superFormErrorsToFieldErrors( @@ -116,8 +94,6 @@ export async function upsertRecord( return { ...form, success: false, error, fieldErrors }; } - const formData = - requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData(); const rawId = formData.get('id')?.toString(); const data = form?.data ?? @@ -163,8 +139,20 @@ export async function upsertRecord( await delegate.create({ data }); } catch (e: unknown) { if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') { - const meta = (e as { meta?: { target?: string[] } }).meta; + const meta = (e as { meta?: { target?: string[]; modelName?: string } }).meta; const fields = meta?.target ?? []; + console.error( + `[crud] P2002 on ${meta?.modelName ?? 'unknown'} create. data=`, + data, + `meta.target=`, + meta?.target + ); + if (fields.length === 0) { + return { + success: false, + error: 'A database constraint violation occurred. The record could not be created.' + }; + } return { success: false, error: `A record with this ${fields.join(', ')} already exists.`, diff --git a/src/lib/svelte-admin/registry.ts b/src/lib/svelte-admin/registry.ts index 727a107..e1594a0 100644 --- a/src/lib/svelte-admin/registry.ts +++ b/src/lib/svelte-admin/registry.ts @@ -102,12 +102,7 @@ const models: AdminModelConfig[] = [ category: { include: { parent: true } }, subscriptionLevel: true }, - listFields: [ - 'name', - 'category.name', - 'solfeggioFrequency.name', - 'subscriptionLevel.name' - ], + listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'], searchFields: ['name', 'content'], formFields: [ 'name', diff --git a/src/routes/(protected)/admin/proclamation-categories/+page.svelte b/src/routes/(protected)/admin/proclamation-categories/+page.svelte index 8295311..700e2b0 100644 --- a/src/routes/(protected)/admin/proclamation-categories/+page.svelte +++ b/src/routes/(protected)/admin/proclamation-categories/+page.svelte @@ -1,8 +1,91 @@ - +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/routes/(protected)/admin/proclamation-categories/CategoryForm.svelte b/src/routes/(protected)/admin/proclamation-categories/CategoryForm.svelte new file mode 100644 index 0000000..c851b15 --- /dev/null +++ b/src/routes/(protected)/admin/proclamation-categories/CategoryForm.svelte @@ -0,0 +1,101 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+
+ + + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if} +
+ +
+ + + {#if fieldErrors.parentId} +

{fieldErrors.parentId}

+ {/if} +
+ +
+ +
+
+
diff --git a/src/routes/(protected)/admin/proclamations/+page.server.ts b/src/routes/(protected)/admin/proclamations/+page.server.ts index 4bc9534..9a15822 100644 --- a/src/routes/(protected)/admin/proclamations/+page.server.ts +++ b/src/routes/(protected)/admin/proclamations/+page.server.ts @@ -1,16 +1,73 @@ import type { PageServerLoad } from './$types'; import { getModelConfig } from '$lib/svelte-admin/registry'; -import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud'; +import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud'; +import { prisma } from '$lib/db'; +import { superValidate } from 'sveltekit-superforms/server'; +import { zod4 } from 'sveltekit-superforms/adapters'; +import * as Schemas from '$lib/svelte-admin/schemas'; const config = getModelConfig('proclamations')!; export const load: PageServerLoad = async ({ locals }) => { - return loadRecords(locals, config); + const base = await loadRecords(locals, config); + + const categories = await prisma.proclamationCategory.findMany({ + select: { id: true, name: true, parentId: true }, + orderBy: { name: 'asc' } + }); + + return { ...base, categories }; }; export const actions = { upsert: async ({ request, locals }) => { - return upsertRecord(request, config, locals); + if (!locals.user) { + return { success: false, error: 'Unauthorized' }; + } + + const formData = await request.formData(); + const form = await superValidate(formData, zod4(Schemas.Proclamation)); + if (!form.valid) { + const { error, fieldErrors } = superFormErrorsToFieldErrors( + form.errors as Record + ); + return { success: false, error, fieldErrors }; + } + + const rawId = formData.get('id')?.toString(); + const { parentCategoryId, ...rest } = form.data; + + const data: Record = { + ...rest, + categoryId: rest.categoryId ?? parentCategoryId + }; + + try { + if (rawId) { + const existing = await prisma.proclamation.findUnique({ + where: { id: rawId } + }); + if (!existing) { + return { success: false, error: 'Record not found' }; + } + await prisma.proclamation.update({ + where: { id: rawId }, + data: data as any + }); + } else { + await prisma.proclamation.create({ data: data as any }); + } + } catch (e: unknown) { + if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') { + return { + success: false, + error: 'A record with this name already exists.' + }; + } + throw e; + } + + return { success: true }; }, delete: async ({ request, locals }) => { return deleteRecord(request, config, locals); diff --git a/src/routes/(protected)/admin/proclamations/+page.svelte b/src/routes/(protected)/admin/proclamations/+page.svelte index 5262785..bfa63d5 100644 --- a/src/routes/(protected)/admin/proclamations/+page.svelte +++ b/src/routes/(protected)/admin/proclamations/+page.svelte @@ -1,8 +1,92 @@ - +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte b/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte new file mode 100644 index 0000000..83b24c8 --- /dev/null +++ b/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte @@ -0,0 +1,221 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+
+ + + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if} +
+ +
+ + + {#if fieldErrors.solfeggioFrequencyId} +

{fieldErrors.solfeggioFrequencyId}

+ {/if} +
+ +
+ + + {#if fieldErrors.parentCategoryId} +

{fieldErrors.parentCategoryId}

+ {/if} +
+ +
+ + + {#if fieldErrors.categoryId} +

{fieldErrors.categoryId}

+ {/if} +
+ +
+ + + {#if fieldErrors.subscriptionLevelId} +

{fieldErrors.subscriptionLevelId}

+ {/if} +
+ +
+ +
+ +
+ {#if fieldErrors.content} +

{fieldErrors.content}

+ {/if} +
+ +
+ +
+
+
diff --git a/src/routes/(protected)/admin/profiles/+page.svelte b/src/routes/(protected)/admin/profiles/+page.svelte index 69e75d2..0d4f760 100644 --- a/src/routes/(protected)/admin/profiles/+page.svelte +++ b/src/routes/(protected)/admin/profiles/+page.svelte @@ -3,14 +3,13 @@ import type { ActionResult } from '$lib/svelte-admin/types'; import AdminTable from '$lib/components/admin/AdminTable.svelte'; import AdminModal from '$lib/components/admin/AdminModal.svelte'; - import AdminForm from '$lib/components/admin/AdminForm.svelte'; import Toast from '$lib/components/admin/Toast.svelte'; import { enhance } from '$app/forms'; import { getModelConfig } from '$lib/svelte-admin/registry'; let { data, form }: PageProps = $props(); - let config = $derived(getModelConfig('profiles')!); + const config = getModelConfig('profiles')!; let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2); let modalOpen = $state(false); @@ -135,18 +134,110 @@ {canDelete} recordId={editingRecord?.id as string | undefined} > - +
+ {#if editingRecord?.id} + + {/if} + {#if formError} +
+ {formError} +
+ {/if} +
+
+ + + {#if fieldErrors.userId} +

{fieldErrors.userId}

+ {/if} +
+
+ + + {#if fieldErrors.firstName} +

{fieldErrors.firstName}

+ {/if} +
+
+ + + {#if fieldErrors.lastName} +

{fieldErrors.lastName}

+ {/if} +
+
+ + + {#if fieldErrors.phoneNumber} +

{fieldErrors.phoneNumber}

+ {/if} +
+
+ +
+
+
{#if editingRecord} {@const user = editingRecord.user as Record | undefined} diff --git a/src/routes/(protected)/admin/solfeggio-frequencies/+page.svelte b/src/routes/(protected)/admin/solfeggio-frequencies/+page.svelte index d20d406..e883a5e 100644 --- a/src/routes/(protected)/admin/solfeggio-frequencies/+page.svelte +++ b/src/routes/(protected)/admin/solfeggio-frequencies/+page.svelte @@ -1,8 +1,90 @@ - +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/routes/(protected)/admin/solfeggio-frequencies/FreqForm.svelte b/src/routes/(protected)/admin/solfeggio-frequencies/FreqForm.svelte new file mode 100644 index 0000000..a1384c6 --- /dev/null +++ b/src/routes/(protected)/admin/solfeggio-frequencies/FreqForm.svelte @@ -0,0 +1,96 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+
+ + + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if} +
+ +
+ + + {#if fieldErrors.value} +

{fieldErrors.value}

+ {/if} +
+ +
+ +
+
+
diff --git a/src/routes/(protected)/admin/subscription-levels/+page.svelte b/src/routes/(protected)/admin/subscription-levels/+page.svelte index 0f6dbb2..b2b6823 100644 --- a/src/routes/(protected)/admin/subscription-levels/+page.svelte +++ b/src/routes/(protected)/admin/subscription-levels/+page.svelte @@ -1,8 +1,90 @@ - +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/routes/(protected)/admin/subscription-levels/LevelForm.svelte b/src/routes/(protected)/admin/subscription-levels/LevelForm.svelte new file mode 100644 index 0000000..0e6719f --- /dev/null +++ b/src/routes/(protected)/admin/subscription-levels/LevelForm.svelte @@ -0,0 +1,95 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+
+ + + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if} +
+ +
+ + + {#if fieldErrors.description} +

{fieldErrors.description}

+ {/if} +
+ +
+ +
+
+
diff --git a/src/routes/(protected)/admin/user-roles/+page.svelte b/src/routes/(protected)/admin/user-roles/+page.svelte index c2bd357..6e36504 100644 --- a/src/routes/(protected)/admin/user-roles/+page.svelte +++ b/src/routes/(protected)/admin/user-roles/+page.svelte @@ -1,8 +1,90 @@ - +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/routes/(protected)/admin/user-roles/UserRoleForm.svelte b/src/routes/(protected)/admin/user-roles/UserRoleForm.svelte new file mode 100644 index 0000000..a256c8a --- /dev/null +++ b/src/routes/(protected)/admin/user-roles/UserRoleForm.svelte @@ -0,0 +1,73 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+
+ + + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if} +
+ +
+ +
+
+
diff --git a/src/routes/(protected)/admin/users/+page.svelte b/src/routes/(protected)/admin/users/+page.svelte index 32c8614..5fb05ba 100644 --- a/src/routes/(protected)/admin/users/+page.svelte +++ b/src/routes/(protected)/admin/users/+page.svelte @@ -1,8 +1,91 @@ - +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/routes/(protected)/admin/users/UserForm.svelte b/src/routes/(protected)/admin/users/UserForm.svelte new file mode 100644 index 0000000..26382f2 --- /dev/null +++ b/src/routes/(protected)/admin/users/UserForm.svelte @@ -0,0 +1,79 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+
+ + + {#if fieldErrors.roleId} +

{fieldErrors.roleId}

+ {/if} +
+ +
+ +
+
+