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
@@ -1,49 +1,146 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import {
loadRecords,
upsertRecord,
deleteRecord,
superFormErrorsToFieldErrors
} from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
import { z } from 'zod';
const config = getModelConfig('profiles')!;
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
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');
const ProfileSchema = z.object({
email,
firstName: name,
lastName: name,
phoneNumber: phone
});
const AddressSchema = 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 load: PageServerLoad = async () => {
const records = await prisma.profile.findMany({
include: { user: true, addresses: true },
orderBy: { id: 'asc' }
});
return {
records: records as unknown as Record<string, unknown>[]
};
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
},
upsertAddress: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const form = await superValidate(request, zod4(Schemas.Address));
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(ProfileSchema));
if (!form.valid) {
const { error: addrError, fieldErrors } = superFormErrorsToFieldErrors(
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, addressError: addrError, addressFieldErrors: fieldErrors };
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = {
email: form.data.email,
firstName: form.data.firstName,
lastName: form.data.lastName,
phoneNumber: form.data.phoneNumber
};
if (rawId) {
const existing = await prisma.profile.findUnique({
where: { id: rawId },
include: { user: true }
});
if (!existing) {
return { success: false, action: 'upsert', error: 'Record not found' };
}
const updateData: Record<string, unknown> = { ...data };
if (existing.userId) {
if (existing.user?.email !== data.email) {
const conflict = await prisma.user.findFirst({
where: { email: data.email, id: { not: existing.userId } }
});
if (conflict) {
return {
success: false,
action: 'upsert',
error: 'That email already belongs to another user.'
};
}
await prisma.user.update({ where: { id: existing.userId }, data: { email: data.email } });
}
} else {
const match = await prisma.user.findUnique({ where: { email: data.email } });
if (match) updateData.userId = match.id;
}
await prisma.profile.update({ where: { id: rawId }, data: updateData });
return { success: true, action: 'upsert' };
}
try {
const user = await prisma.user.findUnique({ where: { email: data.email } });
await prisma.profile.create({ data: { ...data, ...(user ? { userId: user.id } : {}) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A profile for this email already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
await prisma.profile.delete({ where: { id: rawId } });
return { success: true, action: 'delete' };
},
upsertAddress: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(AddressSchema));
if (!form.valid) {
const { error: addressError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return {
success: false,
action: 'upsertAddress',
addressError,
addressFieldErrors: fieldErrors
};
}
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
form.data;
if (!profileId) {
return { success: false, addressError: 'Missing profile ID' };
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
}
// Unique-default enforcement: clear defaults of same type before setting
if (isDefaultMailing) {
await prisma.address.updateMany({
where: { profileId, isDefaultMailing: true },
@@ -72,32 +169,6 @@ export const actions = {
};
const created = await prisma.address.create({ data: payload });
return { success: true, address: created };
},
updateUser: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const form = await superValidate(request, zod4(Schemas.UserUpdate));
if (!form.valid) {
const { error: userErr, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, userError: userErr, userFieldErrors: fieldErrors };
}
const { id, email, name } = form.data;
if (!id) {
return { success: false, userError: 'Missing user ID' };
}
const data: Record<string, string> = {};
if (email) data.email = email;
if (name) data.name = name;
if (Object.keys(data).length === 0) {
return { success: false, userError: 'No fields to update' };
}
await prisma.user.update({ where: { id }, data });
return { success: true };
return { success: true, action: 'upsertAddress', address: created };
}
};
@@ -1,487 +1,59 @@
<script lang="ts">
import type { PageProps } from './$types';
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 Toast from '$lib/components/admin/Toast.svelte';
import { enhance } from '$app/forms';
import { getModelConfig } from '$lib/svelte-admin/registry';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import CreateProfileModal from './CreateProfileModal.svelte';
import EditProfileModal from './EditProfileModal.svelte';
let { data, form }: PageProps = $props();
const config = getModelConfig('profiles')!;
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let formError = $state('');
let addressFormOpen = $state(false);
let userFormOpen = $state(false);
let addressError = $state('');
let userError = $state('');
const columns: DataTableColumn[] = [
{ label: 'First Name', value: (r) => r.firstName },
{ label: 'Last Name', value: (r) => r.lastName },
{
label: 'Email',
value: (r) => (r.user as Record<string, unknown> | undefined)?.email ?? r.email ?? ''
},
{ label: 'Phone', value: (r) => r.phoneNumber },
{ label: 'User', value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? '' }
];
let addressFields = $state<Record<string, string>>({
street: '',
city: '',
state: '',
postalCode: '',
country: '',
isDefaultMailing: 'false',
isDefaultBilling: 'false',
isDefaultShipping: 'false'
});
let userEmail = $state('');
let userName = $state('');
$effect(() => {
if (form) {
if ((form as any).addressError) {
addressError = (form as any).addressError;
} else if ((form as any).userError) {
userError = (form as any).userError;
} else if (form.success) {
toastType = 'success';
if ((form as any).address) {
toastMessage = 'Address saved';
const addr = (form as any).address as Record<string, unknown>;
if (editingRecord) {
const addrs = (editingRecord.addresses as Record<string, unknown>[]) ?? [];
editingRecord = {
...editingRecord,
addresses: [...addrs, addr]
};
}
addressFormOpen = false;
addressError = '';
} else {
toastMessage = 'Saved successfully';
closeModal();
}
} else if (!(form as any).addressError && !(form as any).userError) {
formError = (form as ActionResult).error ?? '';
fieldErrors = (form as ActionResult).fieldErrors ?? {};
}
}
});
let createOpen = $state(false);
let editRecord = $state<Record<string, unknown> | null>(null);
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
modalOpen = true;
createOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
addressError = '';
userError = '';
userEmail = ((record.user as Record<string, unknown>)?.email as string) ?? '';
userName = ((record.user as Record<string, unknown>)?.name as string) ?? '';
modalOpen = true;
editRecord = record;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
addressError = '';
userError = '';
function closeCreate() {
createOpen = false;
}
function toggleAddressForm() {
addressFormOpen = !addressFormOpen;
addressError = '';
}
function toggleUserForm() {
userFormOpen = !userFormOpen;
userError = '';
function closeEdit() {
editRecord = null;
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="User Profiles"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Profile"
searchPlaceholder="Search profiles..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<form method="POST" action="?/upsert" use:enhance>
{#if editingRecord?.id}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-userId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User
</label>
<select
id="field-userId"
name="userId"
value={String(editingRecord?.userId ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select User --</option>
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.userId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
{/if}
</div>
<div>
<label
for="field-firstName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
First Name
</label>
<input
type="text"
id="field-firstName"
name="firstName"
value={String(editingRecord?.firstName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label
for="field-lastName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Last Name
</label>
<input
type="text"
id="field-lastName"
name="lastName"
value={String(editingRecord?.lastName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label
for="field-phoneNumber"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Phone Number
</label>
<input
type="text"
id="field-phoneNumber"
name="phoneNumber"
value={String(editingRecord?.phoneNumber ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{editingRecord ? 'Update' : 'Create'}
</button>
</div>
</div>
</form>
<CreateProfileModal open={createOpen} onClose={closeCreate} {form} />
{#if editingRecord}
{@const user = editingRecord.user as Record<string, unknown> | undefined}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User</h3>
<button
type="button"
onclick={toggleUserForm}
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{userFormOpen ? 'Cancel' : 'Edit User'}
</button>
</div>
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
</p>
{#if userFormOpen}
<form
method="POST"
action="?/updateUser"
use:enhance
class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700"
>
<input type="hidden" name="id" value={String(user?.id ?? '')} />
{#if userError}
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
{userError}
</div>
{/if}
<div class="flex flex-col gap-2">
<div>
<label
for="user-name"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Name
</label>
<input
type="text"
id="user-name"
name="name"
value={userName}
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="user-email"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Email
</label>
<input
type="email"
id="user-email"
name="email"
value={userEmail}
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div class="flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Save User
</button>
</div>
</div>
</form>
{/if}
</div>
{/if}
{#if editingRecord}
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
</div>
{#if addressFormOpen}
<form
method="POST"
action="?/upsertAddress"
use:enhance
class="mb-3 rounded border border-gray-200 p-3 dark:border-gray-700"
>
<input type="hidden" name="profileId" value={String(editingRecord.id)} />
{#if addressError}
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
{addressError}
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div>
<label
for="addr-street"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Street
</label>
<input
type="text"
id="addr-street"
name="street"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-city"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
City
</label>
<input
type="text"
id="addr-city"
name="city"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-state"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
State
</label>
<input
type="text"
id="addr-state"
name="state"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-postal"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Postal Code
</label>
<input
type="text"
id="addr-postal"
name="postalCode"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-country"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Country
</label>
<input
type="text"
id="addr-country"
name="country"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
<div class="mt-2 flex flex-wrap gap-4">
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultMailing" class="h-3.5 w-3.5" />
Default Mailing
</label>
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultBilling" class="h-3.5 w-3.5" />
Default Billing
</label>
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultShipping" class="h-3.5 w-3.5" />
Default Shipping
</label>
</div>
<div class="mt-2 flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Save Address
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th
>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">City</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">State</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Zip</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase"
>Country</th
>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">M</th>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">B</th>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">S</th>
</tr>
</thead>
<tbody>
{#each addresses as addr}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1">{addr.street as string}</td>
<td class="px-2 py-1">{addr.city as string}</td>
<td class="px-2 py-1">{addr.state as string}</td>
<td class="px-2 py-1">{addr.postalCode as string}</td>
<td class="px-2 py-1">{addr.country as string}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultShipping ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="text-sm text-gray-500">No addresses on file.</p>
{/if}
</div>
{/if}
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{#if editRecord}
{#key editRecord.id}
<EditProfileModal record={editRecord} open={true} onClose={closeEdit} {canDelete} {form} />
{/key}
{/if}
@@ -0,0 +1,86 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let {
open = false,
onClose,
form
}: {
open?: boolean;
onClose?: () => void;
form?: ActionResult;
} = $props();
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (open) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile created';
setTimeout(() => onClose?.(), 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
</script>
<AdminModal title="Create Profile" {open} {onClose}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance>
<ProfileFields record={null} {fieldErrors} />
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Create Profile
</button>
</div>
</form>
</AdminModal>
@@ -0,0 +1,305 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
addressError?: string;
address?: Record<string, unknown>;
} | null;
let {
record,
open = false,
onClose,
canDelete = false,
form
}: {
record: Record<string, unknown>;
open?: boolean;
onClose?: () => void;
canDelete?: boolean;
form?: ActionResult;
} = $props();
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let addressError = $state('');
let successMessage = $state('');
let addressFormOpen = $state(false);
let addresses = $state<Record<string, unknown>[]>([]);
let addrStreet = $state('');
let addrCity = $state('');
let addrState = $state('');
let addrPostal = $state('');
let addrCountry = $state('');
let addrMailing = $state(false);
let addrBilling = $state(false);
let addrShipping = $state(false);
let lastRecordId = $state<string | null>(null);
let lastForm = $state<ActionResult>(null);
function resetAddressFields() {
addrStreet = '';
addrCity = '';
addrState = '';
addrPostal = '';
addrCountry = '';
addrMailing = false;
addrBilling = false;
addrShipping = false;
}
function seedRecord() {
addresses = (record.addresses as Record<string, unknown>[]) ?? [];
addressFormOpen = false;
addressError = '';
formError = '';
fieldErrors = {};
successMessage = '';
resetAddressFields();
}
$effect(() => {
if (open) {
seedRecord();
}
});
$effect(() => {
const id = String(record.id ?? '');
if (id === lastRecordId) return;
lastRecordId = id;
seedRecord();
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
switch (form.action) {
case 'upsertAddress':
if (form.success && form.address) {
addresses = [...addresses, form.address];
addressFormOpen = false;
addressError = '';
successMessage = 'Address saved';
resetAddressFields();
} else {
successMessage = '';
addressError = form.addressError ?? '';
}
break;
case 'upsert':
default:
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile updated';
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
break;
}
});
function toggleAddressForm() {
addressFormOpen = !addressFormOpen;
addressError = '';
if (addressFormOpen) resetAddressFields();
}
</script>
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance>
<input type="hidden" name="id" value={String(record.id ?? '')} />
<ProfileFields {record} {fieldErrors} />
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Save Profile
</button>
</div>
</form>
<div class="mt-8 border-t border-slate-200 pt-6">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
</div>
{#if addressFormOpen}
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
{#if addressError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{addressError}
</div>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="addr-street" class="mb-1 block text-sm font-medium text-slate-700"
>Street</label
>
<input
id="addr-street"
type="text"
name="street"
required
bind:value={addrStreet}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-city" class="mb-1 block text-sm font-medium text-slate-700">City</label
>
<input
id="addr-city"
type="text"
name="city"
required
bind:value={addrCity}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-state" class="mb-1 block text-sm font-medium text-slate-700"
>State</label
>
<input
id="addr-state"
type="text"
name="state"
required
bind:value={addrState}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-postal" class="mb-1 block text-sm font-medium text-slate-700"
>Postal Code</label
>
<input
id="addr-postal"
type="text"
name="postalCode"
required
bind:value={addrPostal}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-country" class="mb-1 block text-sm font-medium text-slate-700"
>Country</label
>
<input
id="addr-country"
type="text"
name="country"
required
bind:value={addrCountry}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
</div>
<div class="flex flex-wrap gap-4 text-sm text-slate-700">
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
Default Mailing
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultBilling" bind:checked={addrBilling} />
Default Billing
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultShipping" bind:checked={addrShipping} />
Default Shipping
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
>
Save Address
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
>
<th scope="col" class="px-3 py-2">Street</th>
<th scope="col" class="px-3 py-2">City</th>
<th scope="col" class="px-3 py-2">State</th>
<th scope="col" class="px-3 py-2">Zip</th>
<th scope="col" class="px-3 py-2">Country</th>
<th scope="col" class="px-3 py-2">M</th>
<th scope="col" class="px-3 py-2">B</th>
<th scope="col" class="px-3 py-2">S</th>
</tr>
</thead>
<tbody>
{#each addresses as addr, i (String(addr.id ?? i))}
<tr class="bg-white">
<td class="px-3 py-2 whitespace-nowrap">{addr.street as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.city as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.state as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.postalCode as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.country as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultShipping ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="mt-2 text-sm text-slate-600">No addresses on file.</p>
{/if}
</div>
</AdminModal>
@@ -0,0 +1,92 @@
<script lang="ts">
let {
record = null,
fieldErrors = {} as Record<string, string>
}: {
record?: Record<string, unknown> | null;
fieldErrors?: Record<string, string>;
} = $props();
let email = $state('');
let firstName = $state('');
let lastName = $state('');
let phoneNumber = $state('');
$effect(() => {
const rec = record ?? {};
const user = rec.user as Record<string, unknown> | undefined;
email = String((user?.email as string) ?? rec.email ?? '');
firstName = String(rec.firstName ?? '');
lastName = String(rec.lastName ?? '');
phoneNumber = String(rec.phoneNumber ?? '');
});
</script>
<div class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
id="field-email"
type="email"
name="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.email}
<p class="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
{/if}
</div>
<div>
<label for="field-firstName" class="mb-1 block text-sm font-medium text-slate-700"
>First Name</label
>
<input
id="field-firstName"
type="text"
name="firstName"
required
bind:value={firstName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label for="field-lastName" class="mb-1 block text-sm font-medium text-slate-700"
>Last Name</label
>
<input
id="field-lastName"
type="text"
name="lastName"
required
bind:value={lastName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label for="field-phoneNumber" class="mb-1 block text-sm font-medium text-slate-700">
Phone Number
</label>
<input
id="field-phoneNumber"
type="text"
name="phoneNumber"
required
bind:value={phoneNumber}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-sm text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
</div>
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, createFormRequest } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
user: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load, actions } from './+page.server';
const mockProfileFindMany = prisma.profile.findMany as ReturnType<typeof vi.fn>;
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const mockProfileCreate = prisma.profile.create as ReturnType<typeof vi.fn>;
const mockProfileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const mockProfileDelete = prisma.profile.delete as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserFindFirst = prisma.user.findFirst as ReturnType<typeof vi.fn>;
const mockUserUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const validFields = {
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-010-1234'
};
function makeProfileRequest(overrides: Record<string, string> = {}) {
return createFormRequest({ ...validFields, ...overrides });
}
describe('profiles admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('load', () => {
it('returns profiles with user and addresses', async () => {
const records = [
{
id: 'p1',
email: null,
user: { email: 'jane@example.com', name: 'Jane Doe' },
addresses: []
}
];
mockProfileFindMany.mockResolvedValue(records);
const result = await load({} as any);
expect(mockProfileFindMany).toHaveBeenCalledWith({
include: { user: true, addresses: true },
orderBy: { id: 'asc' }
});
expect(result).toEqual({ records });
});
});
describe('upsert - create', () => {
it('links the profile to an existing user with the same email', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
expect(mockProfileCreate).toHaveBeenCalledWith({
data: { ...validFields, userId: 'user-1' }
});
});
it('stores the email on the profile when no user matches', async () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
});
it('returns a friendly error when the email already exists (P2002)', async () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'A profile for this email already exists.'
});
});
});
describe('upsert - update', () => {
it('syncs the linked user email when it changes', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'old@example.com' }
});
mockUserFindFirst.mockResolvedValue(null);
mockUserUpdate.mockResolvedValue({});
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { email: 'new@example.com' }
});
expect(mockProfileUpdate).toHaveBeenCalledWith({
where: { id: 'p1' },
data: { ...validFields, email: 'new@example.com' }
});
});
it('does not touch the user when the email is unchanged', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'jane@example.com' }
});
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindFirst).not.toHaveBeenCalled();
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('rejects a change to an email owned by another user', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'old@example.com' }
});
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'That email already belongs to another user.'
});
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockProfileUpdate).not.toHaveBeenCalled();
});
it('links the unlinked profile when a user matches the new email', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: null,
email: 'jane@example.com',
user: null
});
mockUserFindUnique.mockResolvedValue({ id: 'user-9' });
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileUpdate).toHaveBeenCalledWith({
where: { id: 'p1' },
data: { ...validFields, userId: 'user-9' }
});
});
});
describe('upsert - validation', () => {
it('returns field errors for invalid input', async () => {
const request = makeProfileRequest({ email: 'not-an-email' });
const result = await actions.upsert({ request } as any);
expect(result.success).toBe(false);
expect(result.action).toBe('upsert');
expect((result as { fieldErrors: Record<string, string> }).fieldErrors.email).toBeDefined();
});
it('returns a record-not-found error for a missing profile', async () => {
mockProfileFindUnique.mockResolvedValue(null);
const request = makeProfileRequest({ id: 'missing' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
});
describe('delete', () => {
it('deletes the profile by id', async () => {
mockProfileDelete.mockResolvedValue({});
const locals = createLocals();
const req = createFormRequest({ id: 'p1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'delete' });
expect(mockProfileDelete).toHaveBeenCalledWith({ where: { id: 'p1' } });
});
it('returns an error when id is missing', async () => {
const locals = createLocals();
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
});