admin: Superforms + Zod validation, three-address-defaults schema, profiles toast fix
- Add Superforms + Zod 4 for all admin form validation - Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping) - Update profiles page with three-checkbox address form and toast improvements - Convert all admin page server actions to pass request directly to crud - Export superFormErrorsToFieldErrors helper for profiles server actions - Regenerate Prisma client for new schema fields
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { getDelegate, parseId } from './prisma-delegate';
|
||||
import { extractFormData } from './form-data';
|
||||
import type { AdminModelConfig } from './types';
|
||||
import { superValidate } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import * as Schemas from './schemas';
|
||||
import type { z } from 'zod';
|
||||
|
||||
const modelSchemas: Record<string, z.ZodObject<any>> = {
|
||||
User: Schemas.User,
|
||||
Profile: Schemas.Profile,
|
||||
UserRole: Schemas.UserRole,
|
||||
Proclamation: Schemas.Proclamation,
|
||||
ProclamationCategory: Schemas.ProclamationCategory,
|
||||
SolfeggioFrequency: Schemas.SolfeggioFrequency,
|
||||
SubscriptionLevel: Schemas.SubscriptionLevel,
|
||||
LicenseKey: Schemas.LicenseKey
|
||||
};
|
||||
|
||||
export function superFormErrorsToFieldErrors(superErrors: Record<string, unknown>): {
|
||||
error: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
} {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
let formError = '';
|
||||
for (const [key, value] of Object.entries(superErrors)) {
|
||||
if (key === '_errors' && Array.isArray(value)) {
|
||||
formError = (value[0] as string) ?? '';
|
||||
} else if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'_errors' in value &&
|
||||
Array.isArray((value as Record<string, unknown>)._errors)
|
||||
) {
|
||||
const errMsg = ((value as Record<string, unknown>)._errors as string[])[0];
|
||||
fieldErrors[key] = errMsg;
|
||||
}
|
||||
}
|
||||
return { error: formError, fieldErrors };
|
||||
}
|
||||
|
||||
export async function loadRecords(locals: App.Locals, config: AdminModelConfig) {
|
||||
const delegate = getDelegate(config);
|
||||
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const where = config.accessFilter?.(viewerRoleId);
|
||||
|
||||
const records = await delegate.findMany({
|
||||
where,
|
||||
include: config.include,
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
|
||||
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
|
||||
// For cascade child fields: maps field -> { childValue -> parentValue }
|
||||
const cascadeOptionParentMap: Record<string, Record<string | number, string | number>> = {};
|
||||
|
||||
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
|
||||
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
|
||||
const selectFields: Record<string, boolean> = {
|
||||
[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' }
|
||||
});
|
||||
const mapped = (options as Record<string, unknown>[]).map((opt: Record<string, unknown>) => ({
|
||||
label: String(opt[relConfig.labelField]),
|
||||
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<string | number, string | number> = {};
|
||||
const rows = options as Record<string, unknown>[];
|
||||
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<string, unknown>[],
|
||||
relationOptionsData,
|
||||
cascadeOptionParentMap
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertRecord(
|
||||
requestOrFormData: Request | FormData,
|
||||
config: AdminModelConfig,
|
||||
locals: App.Locals
|
||||
) {
|
||||
const schema = modelSchemas[config.modelName];
|
||||
const form = schema ? await superValidate(requestOrFormData, zod4(schema)) : null;
|
||||
|
||||
if (form && !form.valid) {
|
||||
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
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 ??
|
||||
(extractFormData(formData, config.formFields, config.fieldTypes) as Record<string, unknown>);
|
||||
|
||||
// Strip virtual/UI-only fields from Prisma payload
|
||||
for (const f of config.dataOnlyFields ?? []) {
|
||||
delete data[f];
|
||||
}
|
||||
|
||||
const delegate = getDelegate(config);
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const where = config.accessFilter?.(viewerRoleId);
|
||||
|
||||
if (rawId) {
|
||||
const id = parseId(config, rawId);
|
||||
|
||||
const existing = await delegate.findUnique({
|
||||
where: { id },
|
||||
...((config.include ? { include: config.include } : {}) as object)
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: false, error: 'Record not found' };
|
||||
}
|
||||
|
||||
if (where) {
|
||||
const match = await delegate.findFirst({
|
||||
where: { id, ...where }
|
||||
});
|
||||
if (!match) {
|
||||
return { success: false, error: 'Access denied' };
|
||||
}
|
||||
}
|
||||
|
||||
await delegate.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
try {
|
||||
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 fields = meta?.target ?? [];
|
||||
return {
|
||||
success: false,
|
||||
error: `A record with this ${fields.join(', ')} already exists.`,
|
||||
fieldErrors: Object.fromEntries(fields.map((f) => [f, 'Already taken']))
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRecord(
|
||||
requestOrFormData: Request | FormData,
|
||||
config: AdminModelConfig,
|
||||
locals: App.Locals
|
||||
) {
|
||||
const delegate = getDelegate(config);
|
||||
const formData =
|
||||
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
if (!rawId) return { success: false, error: 'Missing id' };
|
||||
|
||||
const id = parseId(config, rawId);
|
||||
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const where = config.accessFilter?.(viewerRoleId);
|
||||
if (where) {
|
||||
const match = await delegate.findFirst({ where: { id, ...where } });
|
||||
if (!match) return { success: false, error: 'Access denied' };
|
||||
}
|
||||
|
||||
await delegate.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
Reference in New Issue
Block a user