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:
NeilClack
2026-07-07 13:36:23 -05:00
parent 38347f5f24
commit 38d1752f5b
98 changed files with 3408 additions and 1758 deletions
+102 -81
View File
@@ -1,8 +1,8 @@
# svelte-admin
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
registry and get full CRUD UI with dynamic routing, role-based access control, searchable
dropdowns, and related-record panels — no per-model route files needed.
registry and get full CRUD UI with per-model static pages, role-based access control, searchable
tables, FK dropdowns, TipTap editor for rich text, and related-record panels.
## Architecture
@@ -13,27 +13,30 @@ src/lib/svelte-admin/ # Framework core
├── roles.ts # Role hierarchy and access helpers
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
├── introspect.ts # Field label generation and dot-notation resolution
├── form-data.ts # FormData → typed object parser
├── form-data.ts # FormData -> typed object parser
├── crud.ts # CRUD action factory (load, upsert, delete)
└── index.ts # Barrel exports
src/routes/(protected)/admin/ # Dynamic admin routes
src/routes/(protected)/admin/ # Per-model admin pages (no dynamic [model] routing)
├── +layout.server.ts # Role-based access guard
├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── [model]/
│ ├── +page.server.ts # List view + create action
│ ├── +page.svelte # List view UI
│ └── [id]/
│ ├── +page.server.ts # Edit view + update/delete actions
│ └── +page.svelte # Edit view UI
├── users/ # Each model gets its own page directory
├── profiles/
├── addresses/
├── user-roles/
├── proclamations/
├── solfeggio-frequencies/
├── proclamation-categories/
├── subscription-levels/
└── license-keys/ # listOnly — no create/edit modal
src/lib/components/admin/ # Reusable admin UI components
├── AdminTable.svelte # Sortable, searchable data table
├── AdminForm.svelte # Dynamic form with type-aware inputs
├── ComboBox.svelte # Searchable dropdown for FK fields
├── RelatedHasOne.svelte # Display panel for 1:1 related records
└── RelatedHasMany.svelte # Display table for 1:N related records
├── AdminModal.svelte # Modal overlay for CRUD forms
├── TipTap.svelte # Rich-text editor (in components/tiptap/)
```
## Adding a New Model
@@ -74,10 +77,11 @@ src/lib/components/admin/ # Reusable admin UI components
}
```
3. **Done.** Visit `/admin/widgets`. The dynamic `[model]` routes handle list, create, edit,
and delete automatically.
3. **Create the page directory** `src/routes/(protected)/admin/widgets/` with:
- `+page.server.ts` — load records + form actions using `crud.ts` helpers
- `+page.svelte` — render AdminTable + AdminModal with AdminForm
No new route files, no new components. The registry is the only thing you touch.
4. **Done.** Visit `/admin/widgets`. The page handles list, create, edit via modal.
## Configuration Reference
@@ -95,25 +99,26 @@ No new route files, no new components. The registry is the only thing you touch.
| `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
| `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
| `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table. |
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table and no modal. |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as ComboBox dropdowns. |
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as `<select>` dropdowns. |
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages (display only via related records table). |
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
### PrismaScalarType
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap'`
Controls both form input rendering and FormData parsing:
| Type | HTML Input | Parse Logic |
| ---------- | ----------------------------------- | ------------------------------ |
| `String` | `<input type="text">` | `raw.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty → `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty → `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty → `null` |
| Type | HTML Input | Parse Logic |
| ---------- | ------------------------------------- | ------------------------------ |
| `String` | `<input type="text">` | `raw.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty -> `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty -> `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` -> `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty -> `null` |
| `Tiptap` | TipTap rich-text editor (hidden input)| `raw.toString()` |
### RelationOptionConfig
@@ -142,14 +147,18 @@ Controls both form input rendering and FormData parsing:
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
When a field also has a `relationOptions` entry, the framework renders a `ComboBox` (searchable
dropdown) instead of the default input for that type.
When a field also has a `relationOptions` entry, the framework renders a `<select>` dropdown
instead of the default input for that type. The options are fetched server-side by `crud.ts`
and passed to the client as `relationOptionsData`.
For `Tiptap` fields, the TipTap rich-text editor is rendered. The editor content is stored in a
hidden input and submitted with the form.
## Relations
### Foreign Key Dropdowns (relationOptions)
When a form field is a foreign key, add a `relationOptions` entry to render a searchable ComboBox:
When a form field is a foreign key, add a `relationOptions` entry to render a `<select>` dropdown:
```ts
{
@@ -165,7 +174,8 @@ When a form field is a foreign key, add a `relationOptions` entry to render a se
}
```
The route loader automatically queries the related model and passes options to the ComboBox.
The `crud.ts` `loadRecords` function automatically queries the related model and passes options
to the page as `relationOptionsData`.
### Eager Loading (include)
@@ -200,9 +210,7 @@ relatedRecords: [
];
```
The `relationKey` must match a key in `include` so the data is actually loaded. `has-one` renders
a key-value panel via `RelatedHasOne`; `has-many` renders a table via `RelatedHasMany`. Both
support an optional `editSlug` to link to the related record's edit page.
The `relationKey` must match a key in `include` so the data is actually loaded.
## Access Control
@@ -228,23 +236,23 @@ Use `accessFilter` to restrict which records a viewer can see:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
}
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
if (viewerRoleId <= ROLES.SUPER) return undefined;
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } };
}
return { roleId: { gt: ROLES.STAFF } };
};
```
## How Dynamic Routing Works
## How Static Pages Work
1. User visits `/admin/proclamations` (or `/admin/proclamations/abc123`)
2. SvelteKit matches the `[model]` (and `[id]`) route params
3. `+page.server.ts` calls `getModelConfig(params.model)` to look up the config
4. `getDelegate(config)` returns `prisma[lcfirst(config.modelName)]` (e.g., `prisma.proclamation`)
5. The delegate runs `findMany` (list), `findUnique` (edit), `create`, `update`, or `delete`
6. Data + config are passed to the Svelte page component
7. Components (`AdminTable`, `AdminForm`, etc.) render based on config
1. User visits `/admin/proclamations`
2. SvelteKit matches the static route `/admin/proclamations/+page.server.ts`
3. `+page.server.ts` calls `getModelConfig('proclamations')` to look up the config
4. `loadRecords(config)` calls `prisma.proclamation.findMany()` with the config's `include` and `accessFilter`
5. For each FK field with a `relationOptions`, it also queries the related model for dropdown options
6. Data + config are passed to `+page.svelte`
7. The page renders `AdminTable` with records and `AdminModal` + `AdminForm` for create/edit
## Component API
@@ -255,11 +263,13 @@ accessFilter: (viewerRoleId: number) => {
| `records` | `Record<string, unknown>[]` | Data rows |
| `listFields` | `string[]` | Column keys (dot-notation supported) |
| `searchFields` | `string[]` | Fields for client-side search |
| `modelSlug` | `string` | Used for building edit links |
| `listOnly` | `boolean?` | Suppress edit links |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for cell formatting |
| `singular` | `string` | Singular label for UI text |
| `onRowClick` | `(record) => void?` | Click handler for edit modal |
| `onNew` | `() => void?` | Click handler for create modal |
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
decimals), filtered count display.
decimals), empty state.
### AdminForm
@@ -270,46 +280,31 @@ decimals), filtered count display.
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
| `form` | `ActionData?` | Carries validation errors |
| `action` | `string` | Form action (e.g., `'?/create'`) |
| `readonlyFields` | `string[]?` | Read-only field names |
| `action` | `string` | Form action (e.g., `'?/upsert'`) |
| `submitLabel` | `string?` | Button text |
### ComboBox
### AdminModal
| Prop | Type | Description |
| ------------- | -------------------- | ------------------ |
| `id` | `string` | Element ID |
| `name` | `string` | Form field name |
| `options` | `{ label; value }[]` | Dropdown items |
| `placeholder` | `string?` | Placeholder text |
| `value` | `string \| number?` | Pre-selected value |
| Prop | Type | Description |
| --------- | ----------------- | ------------------------ |
| `open` | `boolean` | Show/hide modal |
| `title` | `string` | Modal heading |
| `onClose` | `() => void` | Close handler |
| `children`| `Snippet` | Modal body content |
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape),
clear button, scroll-to-highlight.
### TipTap
### RelatedHasOne
| Prop | Type | Description |
| --------------- | --------------------------------- | ------------------------------------ |
| `label` | `string` | Section heading |
| `record` | `Record<string, unknown> \| null` | Related record |
| `displayFields` | `string[]` | Fields to display as key-value pairs |
| `editSlug` | `string?` | Links to edit the related record |
### RelatedHasMany
| Prop | Type | Description |
| --------------- | --------------------------- | ----------------------------- |
| `label` | `string` | Section heading (shows count) |
| `records` | `Record<string, unknown>[]` | Related records |
| `displayFields` | `string[]` | Table columns |
| `editSlug` | `string?` | Per-row edit links |
| Prop | Type | Description |
| ---------------- | -------- | --------------------------------- |
| `initialContent` | `string` | Initial HTML content |
| `name` | `string` | Hidden input name for form submit |
## Utility Functions
| Function | Module | Description |
| ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------- |
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` → `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` -> `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
@@ -318,6 +313,32 @@ clear button, scroll-to-highlight.
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
| `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model |
| `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data |
| `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID |
## Models in Admin
| Registry Slug | Prisma Model | Notes |
| -------------------------- | ---------------------- | ---------------------------------- |
| `users` | User | Role assignment, view-only fields |
| `profiles` | Profile | Linked to user FK |
| `addresses` | Address | Linked to profile FK |
| `user-roles` | UserRole | |
| `proclamations` | Proclamation | TipTap rich-text editor for content|
| `solfeggio-frequencies` | SolfeggioFrequency | |
| `proclamation-categories` | ProclamationCategory | Parent category hierarchy |
| `license-keys` | LicenseKey | listOnly — read-only table, no CRUD|
| `subscription-levels` | SubscriptionLevel | |
### Excluded from Admin
| Model | Reason |
| ------------------ | -------------------------------------------- |
| Session | Better Auth internal — not for manual editing|
| Account | Better Auth internal |
| Verification | Better Auth internal |
| UserProclamations | Composite key — managed via other models |
## Complete Example
@@ -364,7 +385,7 @@ A hypothetical `Product` model showing all configuration features:
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= 2) return undefined;
return { active: true }; // staff only sees active products
return { active: true };
}
}
```
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, mockUser } from '$lib/test-utils';
import { getModelConfig } from './registry';
vi.mock('$lib/db', () => ({
prisma: {
userRole: {
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { loadRecords, upsertRecord, deleteRecord } from './crud';
const findMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
const config = getModelConfig('user-roles')!;
describe('crud loadRecords', () => {
beforeEach(() => {
findMany.mockReset();
});
it('returns records', async () => {
findMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config);
expect(result.records).toEqual([{ id: 1, name: 'Admin' }]);
});
it('returns empty records array', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config);
expect(result.records).toEqual([]);
});
});
describe('crud upsertRecord', () => {
beforeEach(() => {
create.mockReset();
update.mockReset();
findUnique.mockReset();
findFirst.mockReset();
});
it('creates a new record when no id', async () => {
create.mockResolvedValue({ id: 2, name: 'Editor' });
const fd = new FormData();
fd.set('name', 'Editor');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(create).toHaveBeenCalledWith({ data: { name: 'Editor' } });
expect(result).toEqual({ success: true });
});
it('updates an existing record when id is present', async () => {
findUnique.mockResolvedValue({ id: 1, name: 'Old Name' });
const fd = new FormData();
fd.set('id', '1');
fd.set('name', 'New Name');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(update).toHaveBeenCalledWith({ where: { id: 1 }, data: { name: 'New Name' } });
expect(result).toEqual({ success: true });
});
it('returns error when updating non-existent record', async () => {
findUnique.mockResolvedValue(null);
const fd = new FormData();
fd.set('id', '999');
fd.set('name', 'Ghost');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({ success: false, error: 'Record not found' });
});
});
describe('crud deleteRecord', () => {
beforeEach(() => {
deletes.mockReset();
});
it('deletes a record by id', async () => {
deletes.mockResolvedValue({ id: 1, name: 'Admin' });
const fd = new FormData();
fd.set('id', '1');
const locals = createLocals(mockUser() as any);
const result = await deleteRecord(fd, config, locals);
expect(deletes).toHaveBeenCalledWith({ where: { id: 1 } });
expect(result).toEqual({ success: true });
});
it('returns error when id is missing', async () => {
const fd = new FormData();
const locals = createLocals(mockUser() as any);
const result = await deleteRecord(fd, config, locals);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
+202
View File
@@ -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 };
}
+4
View File
@@ -31,6 +31,10 @@ export function extractFormData(
data[field] = val === '' ? null : new Date(val);
break;
}
case 'Tiptap': {
data[field] = raw?.toString() ?? '';
break;
}
default: {
data[field] = raw?.toString() ?? '';
break;
+74
View File
@@ -0,0 +1,74 @@
import { z } from 'zod';
const email = z.string().email('Invalid email address');
const phone = z
.string()
.min(10, 'Phone number must be at least 10 digits')
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
const name = z.string().min(1, 'Required').max(100, 'Too long');
export const UserRole = z.object({
name: z.string().min(1, 'Required')
});
export const User = z.object({
name: z.string().min(1, 'Required'),
email,
roleId: z.coerce.number().int().min(1)
});
export const Profile = z.object({
userId: z.string().min(1, 'Required'),
firstName: name,
lastName: name,
phoneNumber: phone
});
export const Address = z.object({
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
postalCode: z.string().min(1, 'Postal code is required'),
country: z.string().min(1, 'Country is required'),
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
isDefaultMailing: z.coerce.boolean().default(false),
isDefaultBilling: z.coerce.boolean().default(false),
isDefaultShipping: z.coerce.boolean().default(false)
});
export const SolfeggioFrequency = z.object({
name: z.string().min(1, 'Required'),
value: z.coerce.number().int('Must be a whole number')
});
export const SubscriptionLevel = z.object({
name: z.string().min(1, 'Required'),
description: z.string().optional().default('')
});
export const ProclamationCategory = z.object({
name: z.string().min(1, 'Required'),
parentId: z.coerce.number().int().optional()
});
export const Proclamation = z.object({
name: z.string().min(1, 'Required'),
solfeggioFrequencyId: z.coerce.number().int(),
parentCategoryId: z.coerce.number().int(),
categoryId: z.coerce.number().int(),
subscriptionLevelId: z.coerce.number().int(),
content: z.string().optional().default('')
});
export const LicenseKey = z.object({
active: z.coerce.boolean(),
level: z.string().min(1, 'Required'),
userId: z.string().min(1, 'Required')
});
export const UserUpdate = z.object({
id: z.string().min(1),
name: z.string().min(1, 'Required').optional(),
email: z.string().email().optional()
});
+9
View File
@@ -19,6 +19,12 @@ export type RelatedRecordConfig = {
editSlug?: string;
};
export type ActionResult = {
success: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export type AdminModelConfig = {
slug: string;
modelName: string;
@@ -35,4 +41,7 @@ export type AdminModelConfig = {
relationOptions?: Record<string, RelationOptionConfig>;
relatedRecords?: RelatedRecordConfig[];
accessFilter?: (viewerRoleId: number) => Record<string, unknown> | undefined;
/** Fields that should be excluded from Prisma create/update (virtual/UI-only) */
dataOnlyFields?: string[];
cascadeFields?: Record<string, { parentField: string }>;
};