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:
+102
-81
@@ -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 };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
@@ -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 }>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user