- {model.displayName}
-
+
+ {model.displayName}
+
{model.listFields.length} fields
diff --git a/src/routes/(protected)/admin/api-keys/+page.svelte b/src/routes/(protected)/admin/api-keys/+page.svelte
index 5884a5c..f362316 100644
--- a/src/routes/(protected)/admin/api-keys/+page.svelte
+++ b/src/routes/(protected)/admin/api-keys/+page.svelte
@@ -8,7 +8,6 @@
let mode = $state<'normal' | 'emergency' | null>(null);
let expiryDate = $state(null);
let toastMessage = $state('');
- let toastType = $state<'success' | 'error'>('success');
function handleRotate(emergency: boolean) {
return ({ cancel }: any) => {
@@ -29,7 +28,6 @@
}
} else {
toastMessage = 'Rotation failed';
- toastType = 'error';
}
};
};
@@ -38,7 +36,6 @@
function copyKey() {
navigator.clipboard.writeText(newKey);
toastMessage = 'Copied to clipboard';
- toastType = 'success';
}
function dismissKey() {
@@ -48,44 +45,26 @@
}
-
-
API Key Management
+
+
API Key Management
{#if newKey}
-
-
+
+
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
-
- This key will not be shown again. Copy it now.
-
-
-
+ This key will not be shown again. Copy it now.
+
+
{newKey}
-
-
-
+
+
+
{#if mode === 'normal' && expiryDate}
-
+
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
to give in-field devices time to update.
@@ -94,23 +73,17 @@
{/if}
{#if toastMessage}
-
+
{toastMessage}
{/if}
-
-
Current Key Status
+
+
Current Key Status
{#if data.activeKey}
-
+
- Prefix: {data.activeKey.prefix}
+ Prefix: {data.activeKey.prefix}
Created: {new Date(data.activeKey.createdAt).toLocaleString()}
{#if data.activeKey.expiresAt}
@@ -118,31 +91,21 @@
{/if}
{:else}
-
No active API key. Generate one below.
+
No active API key. Generate one below.
{/if}
{#if data.totalRotated > 0}
-
+
{data.totalRotated} previous key(s) have been rotated.
{/if}
-
diff --git a/src/routes/(protected)/admin/license-keys/+page.server.ts b/src/routes/(protected)/admin/license-keys/+page.server.ts
index 3debcee..8394dbe 100644
--- a/src/routes/(protected)/admin/license-keys/+page.server.ts
+++ b/src/routes/(protected)/admin/license-keys/+page.server.ts
@@ -10,18 +10,7 @@ const config = getModelConfig('license-keys')!;
export const load: PageServerLoad = async ({ locals }) => {
const records = await loadRecords(locals, config);
- const subscriptionLevels = await prisma.subscriptionLevel.findMany({
- select: { id: true, name: true },
- orderBy: { name: 'asc' }
- });
-
- return {
- ...records,
- subscriptionLevels: subscriptionLevels.map((s: { id: number; name: string }) => ({
- label: s.name,
- value: s.name
- }))
- };
+ return { ...records };
};
export const actions = {
@@ -29,20 +18,13 @@ export const actions = {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
- const email = formData.get('email')?.toString();
- const level = formData.get('level')?.toString();
+ const email = formData.get('email')?.toString()?.trim().toLowerCase();
if (!email) {
return { success: false, error: 'Email is required' };
}
- if (!level) {
- return { success: false, error: 'Subscription level is required' };
- }
const user = await prisma.user.findUnique({ where: { email } });
- if (!user) {
- return { success: false, error: 'No user found with that email' };
- }
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
@@ -51,19 +33,21 @@ export const actions = {
data: {
key,
active: true,
- level,
- userId: user.id
+ userId: user?.id,
+ email: user ? undefined : email
}
});
try {
- const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
+ const profile = await prisma.profile.findFirst({
+ where: user ? { userId: user.id } : { email }
+ });
await sendMail({
to: email,
subject: 'Your Rhemawave License Key',
templateId: 'license-key-delivery',
data: {
- firstName: profile?.firstName ?? user.name ?? 'Valued Customer',
+ firstName: profile?.firstName ?? user?.name ?? 'Valued Customer',
license_key: key
}
});
@@ -92,8 +76,8 @@ export const actions = {
data: {
key,
active: true,
- level: existing.level,
- userId: existing.userId
+ userId: existing.userId,
+ email: existing.email
}
});
diff --git a/src/routes/(protected)/admin/license-keys/+page.svelte b/src/routes/(protected)/admin/license-keys/+page.svelte
index 1cbf182..55f8cd9 100644
--- a/src/routes/(protected)/admin/license-keys/+page.svelte
+++ b/src/routes/(protected)/admin/license-keys/+page.svelte
@@ -5,7 +5,6 @@
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
- import LicenseKeyForm from './LicenseKeyForm.svelte';
let { data }: PageProps = $props();
@@ -15,7 +14,9 @@
let replaceOpen = $state(false);
let replaceRecord = $state
| null>(null);
let toastMessage = $state('');
- let toastType = $state<'success' | 'error'>('success');
+
+ let email = $state('');
+ let formError = $state('');
function openCreate() {
createOpen = true;
@@ -40,26 +41,35 @@
if (result.type === 'success' && result.status === 200) {
closeReplace();
toastMessage = 'License key replaced';
- toastType = 'success';
} else {
toastMessage = 'Failed to replace license key';
- toastType = 'error';
}
await update();
};
}
+
+ function createEnhance() {
+ return async ({ result, update }: any) => {
+ if (result.type === 'success' && result.status === 200) {
+ if (result.data?.error) {
+ formError = result.data.error;
+ } else {
+ email = '';
+ formError = '';
+ await update();
+ closeCreate();
+ }
+ } else {
+ await update();
+ }
+ };
+ }
-
-
-
{config.displayName}
-
+
+
+
{config.displayName}
+
-
+
{#if replaceRecord}
-
-
- Generate a new key to replace {String(replaceRecord.key)}?
-
-
-
- User: {String(replaceRecord['user.name'] ?? '')}
-
-
- Level: {String(replaceRecord.level ?? '')}
-
+
+
Generate a new key to replace {String(replaceRecord.key)}?
+
+
User: {String(replaceRecord['user.name'] ?? '')}
-
- The existing key will be deleted and a new one with the same subscription level will be
- created and emailed.
-
-
{/if}
{#if toastMessage}
-
+
{/if}
diff --git a/src/routes/(protected)/admin/license-keys/LicenseKeyForm.svelte b/src/routes/(protected)/admin/license-keys/LicenseKeyForm.svelte
deleted file mode 100644
index 79a43ed..0000000
--- a/src/routes/(protected)/admin/license-keys/LicenseKeyForm.svelte
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
diff --git a/src/routes/(protected)/admin/license-keys/page.server.spec.ts b/src/routes/(protected)/admin/license-keys/page.server.spec.ts
index bb3c0db..bdd47d2 100644
--- a/src/routes/(protected)/admin/license-keys/page.server.spec.ts
+++ b/src/routes/(protected)/admin/license-keys/page.server.spec.ts
@@ -19,9 +19,8 @@ vi.mock('$lib/svelte-admin/crud', () => ({
vi.mock('$lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
- profile: { findUnique: vi.fn() },
- licenseKey: { create: vi.fn() },
- subscriptionLevel: { findMany: vi.fn() }
+ profile: { findFirst: vi.fn() },
+ licenseKey: { create: vi.fn() }
}
}));
@@ -29,7 +28,7 @@ import { prisma } from '$lib/db';
import { actions } from './+page.server';
const mockFindUnique = prisma.user.findUnique as ReturnType
;
-const mockProfileFindUnique = prisma.profile.findUnique as ReturnType;
+const mockProfileFindFirst = prisma.profile.findFirst as ReturnType;
const mockLicenseKeyCreate = prisma.licenseKey.create as ReturnType;
describe('admin license-keys createKey action', () => {
@@ -39,7 +38,7 @@ describe('admin license-keys createKey action', () => {
it('rejects unauthenticated requests', async () => {
const locals = createLocals();
- const req = createFormRequest({ email: 'jane@example.com', level: 'standard' });
+ const req = createFormRequest({ email: 'jane@example.com' });
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
@@ -47,27 +46,36 @@ describe('admin license-keys createKey action', () => {
it('returns error when email is missing', async () => {
const locals = createLocals(mockUser() as any);
- const req = createFormRequest({ level: 'standard' });
+ const req = createFormRequest({});
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Email is required' });
});
- it('returns error when level is missing', async () => {
- const locals = createLocals(mockUser() as any);
- const req = createFormRequest({ email: 'jane@example.com' });
-
- const result = await actions.createKey({ request: req, locals } as any);
- expect(result).toEqual({ success: false, error: 'Subscription level is required' });
- });
-
- it('returns error when user is not found', async () => {
+ it('creates a key with only an email when no user exists yet', async () => {
mockFindUnique.mockResolvedValue(null);
+ mockProfileFindFirst.mockResolvedValue(null);
+ mockLicenseKeyCreate.mockResolvedValue({});
+ sendMail.mockResolvedValue({ id: 'email-1' });
+
const locals = createLocals(mockUser() as any);
- const req = createFormRequest({ email: 'nobody@example.com', level: 'standard' });
+ const req = createFormRequest({ email: 'nobody@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
- expect(result).toEqual({ success: false, error: 'No user found with that email' });
+
+ expect(result).toEqual({ success: true, error: undefined });
+ expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
+
+ const createCall = mockLicenseKeyCreate.mock.calls[0][0];
+ expect(createCall.data.active).toBe(true);
+ expect(createCall.data.userId).toBeUndefined();
+ expect(createCall.data.email).toBe('nobody@example.com');
+
+ expect(sendMail).toHaveBeenCalledOnce();
+ const mailCall = sendMail.mock.calls[0][0];
+ expect(mailCall.to).toBe('nobody@example.com');
+ expect(mailCall.templateId).toBe('license-key-delivery');
+ expect(mailCall.data.firstName).toBe('Valued Customer');
});
it('creates a license key and sends email with license_key variable', async () => {
@@ -75,12 +83,12 @@ describe('admin license-keys createKey action', () => {
const profile = { firstName: 'Jane' };
mockFindUnique.mockResolvedValue(user);
- mockProfileFindUnique.mockResolvedValue(profile);
+ mockProfileFindFirst.mockResolvedValue(profile);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(user as any);
- const req = createFormRequest({ email: user.email, level: 'standard' });
+ const req = createFormRequest({ email: user.email });
const result = await actions.createKey({ request: req, locals } as any);
@@ -90,7 +98,6 @@ describe('admin license-keys createKey action', () => {
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.key).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(createCall.data.active).toBe(true);
- expect(createCall.data.level).toBe('standard');
expect(createCall.data.userId).toBe('user-1');
expect(sendMail).toHaveBeenCalledOnce();
@@ -106,12 +113,12 @@ describe('admin license-keys createKey action', () => {
const user = mockUser();
mockFindUnique.mockResolvedValue(user);
- mockProfileFindUnique.mockResolvedValue(null);
+ mockProfileFindFirst.mockResolvedValue(null);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(user as any);
- const req = createFormRequest({ email: user.email, level: 'premium' });
+ const req = createFormRequest({ email: user.email });
await actions.createKey({ request: req, locals } as any);
@@ -125,14 +132,14 @@ describe('admin license-keys createKey action', () => {
const user = mockUser();
mockFindUnique.mockResolvedValue(user);
- mockProfileFindUnique.mockResolvedValue({ firstName: 'Jane' });
+ mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockRejectedValue(new Error('Resend API down'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const locals = createLocals(user as any);
- const req = createFormRequest({ email: user.email, level: 'standard' });
+ const req = createFormRequest({ email: user.email });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: true, error: undefined });
diff --git a/src/routes/(protected)/admin/proclamation-categories/+page.server.ts b/src/routes/(protected)/admin/proclamation-categories/+page.server.ts
index 91873fd..e1b24f2 100644
--- a/src/routes/(protected)/admin/proclamation-categories/+page.server.ts
+++ b/src/routes/(protected)/admin/proclamation-categories/+page.server.ts
@@ -4,51 +4,25 @@ import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
const config = getModelConfig('proclamation-categories')!;
-export const load: PageServerLoad = async ({ locals }) => {
+export const load: PageServerLoad = async () => {
const delegate = getDelegate(config);
const records = await delegate.findMany({
- where: { parentId: null },
- include: config.include,
- orderBy: { sortOrder: 'asc' }
+ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
});
- const relationOptionsData: Record = {};
-
- for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
- const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
- const selectFields: Record = {
- [relConfig.valueField]: true,
- [relConfig.labelField]: true
- };
- const options = await relDelegate.findMany({
- select: selectFields,
- orderBy: { [relConfig.labelField]: 'asc' }
- });
- const mapped = (options as Record[]).map((opt: Record) => ({
- label: String(opt[relConfig.labelField]),
- value: opt[relConfig.valueField] as string | number
- }));
- relationOptionsData[field] = mapped;
- }
-
return {
records: records as Record[],
- relationOptionsData
+ relationOptionsData: {}
};
};
export const actions = {
- upsert: async ({ request, locals }) => {
+ upsert: async ({ request }) => {
const formData = await request.formData();
const name = (formData.get('name')?.toString() ?? '').trim();
const rawId = formData.get('id')?.toString();
- const childIds = formData.getAll('childId').map((v) => v.toString());
- const childNames = formData
- .getAll('childName')
- .map((v) => v.toString().trim())
- .filter(Boolean);
if (!name) {
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
@@ -57,70 +31,31 @@ export const actions = {
const delegate = getDelegate(config);
try {
- let categoryId: number;
if (rawId) {
- const id = parseInt(rawId, 10);
await delegate.update({
- where: { id },
+ where: { id: parseInt(rawId, 10) },
data: { name }
});
- categoryId = id;
} else {
const maxSort = await delegate.findFirst({
- where: { parentId: null },
orderBy: { sortOrder: 'desc' },
select: { sortOrder: true }
});
- const sortOrder = (maxSort?.sortOrder ?? -1) + 1;
- const created = await delegate.create({
- data: { name, sortOrder }
+ await delegate.create({
+ data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
});
- categoryId = created.id;
}
-
- const existingChildren = await delegate.findMany({
- where: { parentId: categoryId },
- select: { id: true }
- });
- const existingChildIdSet = new Set(existingChildren.map((c: { id: number }) => c.id));
-
- const submittedIdSet = new Set();
-
- for (let i = 0; i < childNames.length; i++) {
- const childName = childNames[i];
- if (!childName) continue;
-
- const childIdRaw = childIds[i] ? parseInt(childIds[i], 10) : null;
-
- if (childIdRaw && existingChildIdSet.has(childIdRaw)) {
- submittedIdSet.add(childIdRaw);
- await delegate.update({
- where: { id: childIdRaw },
- data: { name: childName }
- });
- } else {
- await delegate.create({
- data: { name: childName, parentId: categoryId }
- });
- }
- }
-
- for (const child of existingChildren) {
- if (!submittedIdSet.has(child.id)) {
- await delegate.delete({ where: { id: child.id } });
- }
- }
-
- return { success: true };
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
- error: 'A sub-category with this name already exists under this parent.'
+ error: 'A category with this name already exists.'
};
}
throw e;
}
+
+ return { success: true };
},
delete: async ({ request, locals }) => {
const formData = await request.formData();
@@ -137,8 +72,17 @@ export const actions = {
if (!match) return { success: false, error: 'Access denied' };
}
- await delegate.deleteMany({ where: { parentId: id } });
- await delegate.delete({ where: { id } });
+ try {
+ await delegate.delete({ where: { id } });
+ } catch (e: unknown) {
+ if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
+ return {
+ success: false,
+ error: 'Cannot delete: one or more proclamations use this category.'
+ };
+ }
+ throw e;
+ }
return { success: true };
},
diff --git a/src/routes/(protected)/admin/proclamation-categories/+page.svelte b/src/routes/(protected)/admin/proclamation-categories/+page.svelte
index 24fc370..4c65376 100644
--- a/src/routes/(protected)/admin/proclamation-categories/+page.svelte
+++ b/src/routes/(protected)/admin/proclamation-categories/+page.svelte
@@ -1,12 +1,11 @@
-
-
{config.displayName}
+
+
{config.displayName}
-
+
{#if toastMessage}
-
+
{/if}
diff --git a/src/routes/(protected)/admin/proclamation-categories/CategoryForm.svelte b/src/routes/(protected)/admin/proclamation-categories/CategoryForm.svelte
deleted file mode 100644
index 3b47ed3..0000000
--- a/src/routes/(protected)/admin/proclamation-categories/CategoryForm.svelte
+++ /dev/null
@@ -1,124 +0,0 @@
-
-
-
diff --git a/src/routes/(protected)/admin/proclamation-groups/+page.server.ts b/src/routes/(protected)/admin/proclamation-groups/+page.server.ts
new file mode 100644
index 0000000..bc3269c
--- /dev/null
+++ b/src/routes/(protected)/admin/proclamation-groups/+page.server.ts
@@ -0,0 +1,107 @@
+import type { PageServerLoad } from './$types';
+import { getModelConfig } from '$lib/svelte-admin/registry';
+import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
+
+const config = getModelConfig('proclamation-groups')!;
+
+export const load: PageServerLoad = async () => {
+ const delegate = getDelegate(config);
+
+ const records = await delegate.findMany({
+ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
+ });
+
+ return {
+ records: records as Record[],
+ relationOptionsData: {}
+ };
+};
+
+export const actions = {
+ upsert: async ({ request }) => {
+ const formData = await request.formData();
+
+ const name = (formData.get('name')?.toString() ?? '').trim();
+ const rawId = formData.get('id')?.toString();
+
+ if (!name) {
+ return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
+ }
+
+ const delegate = getDelegate(config);
+
+ try {
+ if (rawId) {
+ await delegate.update({
+ where: { id: parseInt(rawId, 10) },
+ data: { name }
+ });
+ } else {
+ const maxSort = await delegate.findFirst({
+ orderBy: { sortOrder: 'desc' },
+ select: { sortOrder: true }
+ });
+ await delegate.create({
+ data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
+ });
+ }
+ } catch (e: unknown) {
+ if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
+ return {
+ success: false,
+ error: 'A group with this name already exists.'
+ };
+ }
+ throw e;
+ }
+
+ return { success: true };
+ },
+ delete: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const rawId = formData.get('id')?.toString();
+ if (!rawId) return { success: false, error: 'Missing id' };
+
+ const id = parseInt(rawId, 10);
+ const delegate = getDelegate(config);
+
+ 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' };
+ }
+
+ try {
+ await delegate.delete({ where: { id } });
+ } catch (e: unknown) {
+ if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
+ return {
+ success: false,
+ error: 'Cannot delete: one or more proclamations use this group.'
+ };
+ }
+ throw e;
+ }
+
+ return { success: true };
+ },
+ reorder: async ({ request }) => {
+ const formData = await request.formData();
+ const raw = formData.get('ids')?.toString();
+ if (!raw) return { success: false, error: 'Missing ids' };
+
+ const ids = JSON.parse(raw) as number[];
+
+ const delegate = getDelegate(config);
+
+ for (let i = 0; i < ids.length; i++) {
+ await delegate.update({
+ where: { id: ids[i] },
+ data: { sortOrder: i }
+ });
+ }
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/(protected)/admin/subscription-levels/+page.svelte b/src/routes/(protected)/admin/proclamation-groups/+page.svelte
similarity index 58%
rename from src/routes/(protected)/admin/subscription-levels/+page.svelte
rename to src/routes/(protected)/admin/proclamation-groups/+page.svelte
index b2b6823..9ebf18f 100644
--- a/src/routes/(protected)/admin/subscription-levels/+page.svelte
+++ b/src/routes/(protected)/admin/proclamation-groups/+page.svelte
@@ -1,30 +1,31 @@
-
-
{config.displayName}
+
+
{config.displayName}
@@ -77,14 +96,33 @@
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
-
+
{#if toastMessage}
-
+
{/if}
diff --git a/src/routes/(protected)/admin/proclamations/+page.server.ts b/src/routes/(protected)/admin/proclamations/+page.server.ts
index dc2e89c..ea589f3 100644
--- a/src/routes/(protected)/admin/proclamations/+page.server.ts
+++ b/src/routes/(protected)/admin/proclamations/+page.server.ts
@@ -27,11 +27,16 @@ export const load: PageServerLoad = async ({ locals }) => {
}
const categories = await prisma.proclamationCategory.findMany({
- select: { id: true, name: true, parentId: true },
+ select: { id: true, name: true },
orderBy: { name: 'asc' }
});
- return { ...base, categories };
+ const groups = await prisma.proclamationGroup.findMany({
+ select: { id: true, name: true },
+ orderBy: { sortOrder: 'asc' }
+ });
+
+ return { ...base, categories, groups };
};
export const actions = {
@@ -54,12 +59,8 @@ export const actions = {
}
const rawId = formData.get('id')?.toString();
- const { parentCategoryId, ...rest } = form.data;
- const data: Record
= {
- ...rest,
- categoryId: rest.categoryId ?? parentCategoryId
- };
+ const data: Record = { ...form.data };
try {
if (rawId) {
diff --git a/src/routes/(protected)/admin/proclamations/+page.svelte b/src/routes/(protected)/admin/proclamations/+page.svelte
index 554f29d..b7fc091 100644
--- a/src/routes/(protected)/admin/proclamations/+page.svelte
+++ b/src/routes/(protected)/admin/proclamations/+page.svelte
@@ -1,11 +1,15 @@
-
-
{config.displayName}
+
+
{config.displayName}
-
+
{#if toastMessage}
-
+
{/if}
diff --git a/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte b/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte
deleted file mode 100644
index 912e7a4..0000000
--- a/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte
+++ /dev/null
@@ -1,237 +0,0 @@
-
-
-
diff --git a/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte.spec.ts b/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte.spec.ts
deleted file mode 100644
index fcf6edc..0000000
--- a/src/routes/(protected)/admin/proclamations/ProclamationForm.svelte.spec.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { page } from 'vitest/browser';
-import { describe, it, expect, vi } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-
-vi.mock('$app/forms', () => ({
- enhance: () => () => {}
-}));
-
-vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
- default: ($anchor: Element) => {
- const div = document.createElement('div');
- div.setAttribute('data-testid', 'tiptap-mock');
- $anchor.replaceWith(div);
- }
-}));
-
-import ProclamationForm from './ProclamationForm.svelte';
-
-const relationOptionsData = {
- solfeggioFrequencyId: [
- { label: 'UT - 396', value: 1 },
- { label: 'RE - 417', value: 2 }
- ],
- subscriptionLevelId: [
- { label: 'Free', value: 1 },
- { label: 'Premium', value: 2 }
- ]
-};
-
-const categories = [
- { id: 1, name: 'Healing', parentId: null },
- { id: 2, name: 'Manifestation', parentId: null },
- { id: 3, name: 'Physical Healing', parentId: 1 },
- { id: 4, name: 'Emotional Healing', parentId: 1 }
-];
-
-describe('ProclamationForm', () => {
- it('renders all basic fields in create mode', async () => {
- render(ProclamationForm, {
- relationOptionsData,
- categories,
- isSuper: true
- });
-
- await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
- await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
- await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
- await expect
- .element(page.getByRole('combobox', { name: /^Sub-Category$/ }))
- .toBeInTheDocument();
- await expect
- .element(page.getByRole('combobox', { name: /^Subscription Level$/ }))
- .toBeInTheDocument();
- await expect.element(page.getByRole('button', { name: 'Save' })).toBeInTheDocument();
- });
-
- it('pre-fills name input when editing an existing record', async () => {
- render(ProclamationForm, {
- record: {
- id: 'proc-1',
- name: 'Test Proclamation',
- solfeggioFrequencyId: 2,
- subscriptionLevelId: 2,
- categoryId: 3,
- parentCategoryId: 1
- },
- relationOptionsData,
- categories,
- isSuper: true,
- submitLabel: 'Update'
- });
-
- await expect
- .element(page.getByRole('textbox', { name: 'Name' }))
- .toHaveValue('Test Proclamation');
- });
-
- it('pre-fills select values when editing an existing record', async () => {
- render(ProclamationForm, {
- record: {
- id: 'proc-1',
- name: 'Test Proclamation',
- solfeggioFrequencyId: 2,
- subscriptionLevelId: 2,
- categoryId: 3,
- parentCategoryId: 1
- },
- relationOptionsData,
- categories,
- isSuper: true
- });
-
- const freqEl = (await page
- .getByRole('combobox', { name: /^Frequency$/ })
- .element()) as HTMLSelectElement;
- const levelEl = (await page
- .getByRole('combobox', { name: /^Subscription Level$/ })
- .element()) as HTMLSelectElement;
-
- expect(freqEl.value).toBe('2');
- expect(levelEl.value).toBe('2');
- });
-
- it('shows content field when isSuper is true', async () => {
- render(ProclamationForm, {
- record: { id: 'proc-1', name: 'Test', content: 'Secret
' },
- relationOptionsData,
- categories,
- isSuper: true
- });
-
- await expect.element(page.getByText('Content')).toBeInTheDocument();
- await expect.element(page.getByTestId('tiptap-mock')).toBeInTheDocument();
- });
-
- it('hides content field when isSuper is false', async () => {
- render(ProclamationForm, {
- record: { id: 'proc-1', name: 'Test', content: 'Secret
' },
- relationOptionsData,
- categories,
- isSuper: false
- });
-
- await expect.element(page.getByText('Content')).not.toBeInTheDocument();
- });
-
- it('disables submit button when editing and not dirty', async () => {
- render(ProclamationForm, {
- record: { id: 'proc-1', name: 'Test' },
- relationOptionsData,
- categories,
- isSuper: true,
- submitLabel: 'Update'
- });
-
- await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
- });
-
- it('enables submit button when editing and user modifies a field', async () => {
- render(ProclamationForm, {
- record: { id: 'proc-1', name: 'Test' },
- relationOptionsData,
- categories,
- isSuper: true,
- submitLabel: 'Update'
- });
-
- await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
-
- await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
- });
-});
diff --git a/src/routes/(protected)/admin/proclamations/page.server.spec.ts b/src/routes/(protected)/admin/proclamations/page.server.spec.ts
index fa4dd97..d0ee23c 100644
--- a/src/routes/(protected)/admin/proclamations/page.server.spec.ts
+++ b/src/routes/(protected)/admin/proclamations/page.server.spec.ts
@@ -13,6 +13,9 @@ vi.mock('$lib/db', () => ({
},
proclamationCategory: {
findMany: vi.fn()
+ },
+ proclamationGroup: {
+ findMany: vi.fn()
}
}
}));
@@ -31,6 +34,7 @@ const mockLoadRecords = loadRecords as ReturnType;
const mockDeleteRecord = deleteRecord as ReturnType;
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType;
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType;
+const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType;
const mockFindUnique = prisma.proclamation.findUnique as ReturnType;
const mockCreate = prisma.proclamation.create as ReturnType;
const mockUpdate = prisma.proclamation.update as ReturnType;
@@ -46,11 +50,13 @@ describe('proclamations admin page server', () => {
{ id: '2', name: 'B', content: 'secret2
' }
];
const relationOptionsData = {};
- const categories = [{ id: 1, name: 'Healing', parentId: null }];
+ const categories = [{ id: 1, name: 'Healing' }];
+ const groups = [{ id: 1, name: 'General' }];
it('strips content for non-Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
+ mockGroupFindMany.mockResolvedValue(groups);
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const result = await load({ locals } as any);
@@ -61,13 +67,15 @@ describe('proclamations admin page server', () => {
{ id: '2', name: 'B' }
],
relationOptionsData,
- categories
+ categories,
+ groups
});
});
it('keeps content for Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
+ mockGroupFindMany.mockResolvedValue(groups);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await load({ locals } as any);
@@ -75,7 +83,8 @@ describe('proclamations admin page server', () => {
expect(result).toEqual({
records: recordsWithContent,
relationOptionsData,
- categories
+ categories,
+ groups
});
});
});
diff --git a/src/routes/(protected)/admin/proclamations/page.svelte.spec.ts b/src/routes/(protected)/admin/proclamations/page.svelte.spec.ts
new file mode 100644
index 0000000..7758cb8
--- /dev/null
+++ b/src/routes/(protected)/admin/proclamations/page.svelte.spec.ts
@@ -0,0 +1,174 @@
+import { page } from 'vitest/browser';
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import type { PageProps } from './$types';
+
+vi.mock('$app/forms', () => ({
+ enhance: () => () => {}
+}));
+
+import Page from './+page.svelte';
+
+const relationOptionsData = {
+ solfeggioFrequencyId: [
+ { label: 'UT - 396', value: 1 },
+ { label: 'RE - 417', value: 2 }
+ ]
+};
+
+const categories = [
+ { id: 1, name: 'Healing' },
+ { id: 2, name: 'Manifestation' }
+];
+
+const groups = [
+ { id: 1, name: 'General' },
+ { id: 2, name: 'Specific' }
+];
+
+const superAdmin = {
+ id: 'admin-1',
+ name: 'Super Admin',
+ roleId: 1,
+ roleName: 'SUPER'
+};
+
+function makeData(overrides: Partial = {}): PageProps['data'] {
+ return {
+ records: [],
+ relationOptionsData,
+ categories,
+ groups,
+ adminUser: superAdmin,
+ userRoleId: superAdmin.roleId,
+ ...overrides
+ };
+}
+
+function mountPage(dataOverrides: Partial = {}) {
+ return render(Page, { params: {}, data: makeData(dataOverrides), form: null });
+}
+
+const editRecord = {
+ id: 'proc-1',
+ name: 'Test Proclamation',
+ content: 'Secret
',
+ solfeggioFrequencyId: 2,
+ categoryId: 1,
+ groupId: 2,
+ category: { name: 'Healing' },
+ group: { name: 'Specific' },
+ solfeggioFrequency: { name: 'RE - 417' }
+};
+
+async function openCreateModal() {
+ await page.getByRole('button', { name: 'New Proclamation +' }).click();
+}
+
+async function openEditModal() {
+ await page.getByRole('cell', { name: 'Test Proclamation' }).click();
+}
+
+describe('proclamations admin page (super user)', () => {
+ it('renders all basic fields in create mode', async () => {
+ mountPage();
+
+ await openCreateModal();
+
+ await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
+ await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
+ await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
+ await expect.element(page.getByRole('combobox', { name: /^Group$/ })).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Create' })).toBeInTheDocument();
+ });
+
+ it('pre-fills name input when editing an existing record', async () => {
+ mountPage({ records: [editRecord] });
+
+ await openEditModal();
+
+ await expect
+ .element(page.getByRole('textbox', { name: 'Name' }))
+ .toHaveValue('Test Proclamation');
+ });
+
+ it('pre-fills select values when editing an existing record', async () => {
+ mountPage({ records: [editRecord] });
+
+ await openEditModal();
+
+ const freqEl = (await page
+ .getByRole('combobox', { name: /^Frequency$/ })
+ .element()) as HTMLSelectElement;
+ const categoryEl = (await page
+ .getByRole('combobox', { name: /^Category$/ })
+ .element()) as HTMLSelectElement;
+ const groupEl = (await page
+ .getByRole('combobox', { name: /^Group$/ })
+ .element()) as HTMLSelectElement;
+
+ expect(freqEl.value).toBe('2');
+ expect(categoryEl.value).toBe('1');
+ expect(groupEl.value).toBe('2');
+ });
+
+ it('keeps group selection independent of category selection (movable filter)', async () => {
+ mountPage({ records: [editRecord] });
+
+ await openEditModal();
+
+ const categoryEl = (await page
+ .getByRole('combobox', { name: /^Category$/ })
+ .element()) as HTMLSelectElement;
+ const groupEl = (await page
+ .getByRole('combobox', { name: /^Group$/ })
+ .element()) as HTMLSelectElement;
+
+ categoryEl.value = '2';
+ categoryEl.dispatchEvent(new Event('change', { bubbles: true }));
+
+ expect(categoryEl.value).toBe('2');
+ expect(groupEl.value).toBe('2');
+ });
+
+ it('shows content editor when isSuper is true', async () => {
+ mountPage({ records: [editRecord] });
+
+ await openEditModal();
+
+ await expect.element(page.getByText('Content')).toBeInTheDocument();
+ await expect.element(page.getByTestId('tiptap-editor')).toBeInTheDocument();
+ });
+
+ it('disables submit button when editing and not dirty', async () => {
+ mountPage({ records: [editRecord] });
+
+ await openEditModal();
+
+ await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
+ });
+
+ it('enables submit button when editing and user modifies a field', async () => {
+ mountPage({ records: [editRecord] });
+
+ await openEditModal();
+
+ await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
+
+ await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
+ });
+});
+
+describe('proclamations admin page (non-super user)', () => {
+ it('hides create/edit entry points and content field from non-super users', async () => {
+ mountPage({
+ records: [editRecord],
+ adminUser: { ...superAdmin, id: 'admin-2', roleId: 2, roleName: 'ADMIN' }
+ });
+
+ await expect
+ .element(page.getByRole('button', { name: 'New Proclamation +' }))
+ .not.toBeInTheDocument();
+ await expect.element(page.getByText('Content')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/routes/(protected)/admin/profiles/+page.svelte b/src/routes/(protected)/admin/profiles/+page.svelte
index 0d4f760..cc60bd0 100644
--- a/src/routes/(protected)/admin/profiles/+page.svelte
+++ b/src/routes/(protected)/admin/profiles/+page.svelte
@@ -15,7 +15,6 @@
let modalOpen = $state(false);
let editingRecord = $state | null>(null);
let toastMessage = $state('');
- let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state>({});
let formError = $state('');
let addressFormOpen = $state(false);
@@ -44,7 +43,6 @@
} 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;
@@ -112,8 +110,8 @@
}
-
-
{config.displayName}
+
+
{config.displayName}
{/if}
{#if formError}
-
+
{formError}
{/if}
-
+
-
-
-
+
+
+ {#if fieldErrors.email}
+
{fieldErrors.email}
+ {/if}
+
+
+
{#if fieldErrors.firstName}
-
{fieldErrors.firstName}
+
{fieldErrors.firstName}
{/if}
-
+
{#if fieldErrors.lastName}
-
{fieldErrors.lastName}
+
{fieldErrors.lastName}
{/if}
-
+
{#if fieldErrors.phoneNumber}
-
{fieldErrors.phoneNumber}
+
{fieldErrors.phoneNumber}
{/if}
-
-