Refactor admin to shared generic CRUD; restructure proclamation categories/groups and APIs
- Replace per-model admin forms with the shared generic AdminTable/AdminModal CRUD - Add ProclamationGroup model and admin + API v2 routes (replaces subscription levels) - Drop subscription levels and flatten proclamation categories - Update v2 serializers and seed data accordingly
This commit is contained in:
+26
-43
@@ -72,43 +72,26 @@ const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const subscriptionLevels = [
|
const proclamationGroups = [
|
||||||
{ id: 1, name: 'Basic', description: 'General only' },
|
{ name: 'General', sortOrder: 0 },
|
||||||
{ id: 2, name: 'Advanced', description: 'General + Specific' },
|
{ name: 'Specific', sortOrder: 1 },
|
||||||
{ id: 3, name: 'Elite', description: 'General + Specific + Precise' }
|
{ name: 'Precise', sortOrder: 2 },
|
||||||
|
{ name: 'Prayers', sortOrder: 3 },
|
||||||
|
{ name: 'Scripture', sortOrder: 4 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Flat category list (formerly hierarchical parents).
|
||||||
const proclamationCategories = [
|
const proclamationCategories = [
|
||||||
{ id: 4, name: 'Spiritual', parentId: null },
|
{ id: 4, name: 'Spiritual' },
|
||||||
{ id: 5, name: 'General', parentId: 4 },
|
{ id: 2, name: 'Physical' },
|
||||||
{ id: 9, name: 'Specific', parentId: 4 },
|
{ id: 16, name: 'Mental' },
|
||||||
{ id: 40, name: 'Precise', parentId: 4 },
|
{ id: 17, name: 'Custom (Client Specific)' },
|
||||||
{ id: 2, name: 'Physical', parentId: null },
|
{ id: 19, name: 'Financial' },
|
||||||
{ id: 3, name: 'General', parentId: 2 },
|
{ id: 20, name: 'Emotional' },
|
||||||
{ id: 10, name: 'Specific', parentId: 2 },
|
{ id: 21, name: 'Social' },
|
||||||
{ id: 32, name: 'Precise', parentId: 2 },
|
{ id: 41, name: 'Biblical' },
|
||||||
{ id: 16, name: 'Mental', parentId: null },
|
{ id: 42, name: 'Personal Growth & Development' },
|
||||||
{ id: 30, name: 'General', parentId: 16 },
|
{ id: 46, name: 'Sports' }
|
||||||
{ id: 31, name: 'Specific', parentId: 16 },
|
|
||||||
{ id: 38, name: 'Precise', parentId: 16 },
|
|
||||||
{ id: 17, name: 'Custom (Client Specific)', parentId: null },
|
|
||||||
{ id: 19, name: 'Financial', parentId: null },
|
|
||||||
{ id: 28, name: 'General', parentId: 19 },
|
|
||||||
{ id: 29, name: 'Specific', parentId: 19 },
|
|
||||||
{ id: 37, name: 'Precise', parentId: 19 },
|
|
||||||
{ id: 20, name: 'Emotional', parentId: null },
|
|
||||||
{ id: 24, name: 'General', parentId: 20 },
|
|
||||||
{ id: 26, name: 'Specific', parentId: 20 },
|
|
||||||
{ id: 45, name: 'Precise', parentId: 20 },
|
|
||||||
{ id: 21, name: 'Social', parentId: null },
|
|
||||||
{ id: 22, name: 'General', parentId: 21 },
|
|
||||||
{ id: 23, name: 'Specific', parentId: 21 },
|
|
||||||
{ id: 39, name: 'Precise', parentId: 21 },
|
|
||||||
{ id: 41, name: 'Biblical', parentId: null },
|
|
||||||
{ id: 42, name: 'Personal Growth & Development', parentId: null },
|
|
||||||
{ id: 43, name: 'Prayers', parentId: 41 },
|
|
||||||
{ id: 44, name: 'Scripture', parentId: 41 },
|
|
||||||
{ id: 46, name: 'Sports', parentId: null }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -130,6 +113,15 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Seeding ProclamationGroup...');
|
||||||
|
for (const group of proclamationGroups) {
|
||||||
|
await prisma.proclamationGroup.upsert({
|
||||||
|
where: { name: group.name },
|
||||||
|
create: group,
|
||||||
|
update: group
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Seeding ProclamationCategory...');
|
console.log('Seeding ProclamationCategory...');
|
||||||
for (const cat of proclamationCategories) {
|
for (const cat of proclamationCategories) {
|
||||||
await prisma.proclamationCategory.upsert({
|
await prisma.proclamationCategory.upsert({
|
||||||
@@ -139,15 +131,6 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Seeding SubscriptionLevel...');
|
|
||||||
for (const level of subscriptionLevels) {
|
|
||||||
await prisma.subscriptionLevel.upsert({
|
|
||||||
where: { id: level.id },
|
|
||||||
create: level,
|
|
||||||
update: level
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Seeding ColorPalette...');
|
console.log('Seeding ColorPalette...');
|
||||||
for (const palette of colorPalettes) {
|
for (const palette of colorPalettes) {
|
||||||
await prisma.colorPalette.upsert({
|
await prisma.colorPalette.upsert({
|
||||||
|
|||||||
@@ -43,38 +43,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div role="presentation" onclick={handleBackdrop} onkeydown={handleKeydown}>
|
||||||
role="presentation"
|
<div>
|
||||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-12"
|
<div>
|
||||||
onclick={handleBackdrop}
|
<h2>{title}</h2>
|
||||||
onkeydown={handleKeydown}
|
<div>
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="my-8 w-full max-w-2xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if canDelete && recordId}
|
{#if canDelete && recordId}
|
||||||
<button
|
<button type="button" onclick={confirmDelete}> Delete </button>
|
||||||
type="button"
|
|
||||||
onclick={confirmDelete}
|
|
||||||
class="px-3 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button type="button" onclick={onClose}>×</button>
|
||||||
type="button"
|
|
||||||
onclick={onClose}
|
|
||||||
class="px-2 py-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
||||||
>×</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4">
|
<div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,8 +54,6 @@
|
|||||||
: filtered
|
: filtered
|
||||||
);
|
);
|
||||||
|
|
||||||
let isDragging = $state(false);
|
|
||||||
|
|
||||||
let dragOverride = $state<Record<string, unknown>[] | null>(null);
|
let dragOverride = $state<Record<string, unknown>[] | null>(null);
|
||||||
|
|
||||||
let rows = $derived(dragOverride ?? sorted);
|
let rows = $derived(dragOverride ?? sorted);
|
||||||
@@ -74,7 +72,6 @@
|
|||||||
|
|
||||||
function handleDragStart(e: DragEvent, i: number) {
|
function handleDragStart(e: DragEvent, i: number) {
|
||||||
dragOverride = sorted;
|
dragOverride = sorted;
|
||||||
isDragging = true;
|
|
||||||
dragIndex = i;
|
dragIndex = i;
|
||||||
hoverIndex = i;
|
hoverIndex = i;
|
||||||
e.dataTransfer!.effectAllowed = 'move';
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
@@ -99,14 +96,12 @@
|
|||||||
if (dragIndex === null) return;
|
if (dragIndex === null) return;
|
||||||
const ids = rows.map((r) => r.id as string | number);
|
const ids = rows.map((r) => r.id as string | number);
|
||||||
await onReorder?.(ids);
|
await onReorder?.(ids);
|
||||||
isDragging = false;
|
|
||||||
dragIndex = null;
|
dragIndex = null;
|
||||||
hoverIndex = null;
|
hoverIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd() {
|
function handleDragEnd() {
|
||||||
dragOverride = null;
|
dragOverride = null;
|
||||||
isDragging = false;
|
|
||||||
dragIndex = null;
|
dragIndex = null;
|
||||||
hoverIndex = null;
|
hoverIndex = null;
|
||||||
}
|
}
|
||||||
@@ -131,45 +126,32 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div>
|
||||||
{#if searchFields.length > 0}
|
{#if searchFields.length > 0}
|
||||||
<input
|
<input type="text" placeholder="Search {singular}s..." bind:value={search} />
|
||||||
type="text"
|
|
||||||
placeholder="Search {singular}s..."
|
|
||||||
bind:value={search}
|
|
||||||
class="max-w-xs border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div></div>
|
<div></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onNew}
|
{#if onNew}
|
||||||
<button
|
<button type="button" onclick={onNew}>
|
||||||
type="button"
|
|
||||||
onclick={onNew}
|
|
||||||
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
New {singular} +
|
New {singular} +
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
|
<div>
|
||||||
<table class="w-full">
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
|
<tr>
|
||||||
{#if draggable}
|
{#if draggable}
|
||||||
<th scope="col" class="w-10 px-2 py-2"></th>
|
<th scope="col"></th>
|
||||||
{/if}
|
{/if}
|
||||||
{#each listFields as field (field)}
|
{#each listFields as field (field)}
|
||||||
<th
|
<th scope="col" onclick={() => toggleSort(field)}>
|
||||||
scope="col"
|
|
||||||
onclick={() => toggleSort(field)}
|
|
||||||
class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
||||||
>
|
|
||||||
{fieldNameToLabel(field)}
|
{fieldNameToLabel(field)}
|
||||||
{#if sortCol === field}
|
{#if sortCol === field}
|
||||||
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
|
<span>{sortAsc ? '\u25B2' : '\u25BC'}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</th>
|
</th>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -184,25 +166,15 @@
|
|||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
ondragend={handleDragEnd}
|
ondragend={handleDragEnd}
|
||||||
onclick={() => onRowClick?.(record)}
|
onclick={() => onRowClick?.(record)}
|
||||||
class="border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 {draggable &&
|
|
||||||
isDragging &&
|
|
||||||
dragIndex === i
|
|
||||||
? 'opacity-40'
|
|
||||||
: ''} {draggable && isDragging && hoverIndex === i
|
|
||||||
? 'border-t-2 border-t-blue-500'
|
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
{#if draggable}
|
{#if draggable}
|
||||||
<td
|
<td onclick={(e) => e.stopPropagation()}>
|
||||||
class="w-10 cursor-grab px-2 py-2 text-center"
|
<span>⠿</span>
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<span class="text-gray-400 select-none">⠿</span>
|
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
{#each listFields as field (field)}
|
{#each listFields as field (field)}
|
||||||
<td class="px-4 py-2 text-sm">
|
<td>
|
||||||
<span class="inline-flex items-center gap-1.5">
|
<span>
|
||||||
{formatCellValue(resolveFieldValue(record, field), field)}
|
{formatCellValue(resolveFieldValue(record, field), field)}
|
||||||
{#if copyableFields.includes(field)}
|
{#if copyableFields.includes(field)}
|
||||||
<button
|
<button
|
||||||
@@ -212,10 +184,8 @@
|
|||||||
copyValue(resolveFieldValue(record, field));
|
copyValue(resolveFieldValue(record, field));
|
||||||
}}
|
}}
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="size-3.5"
|
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -234,10 +204,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colspan={listFields.length + (draggable ? 1 : 0)}>
|
||||||
colspan={listFields.length + (draggable ? 1 : 0)}
|
|
||||||
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
No {singular}s found.
|
No {singular}s found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let { message = '' }: { message?: string } = $props();
|
||||||
message = '',
|
|
||||||
type = 'success' as 'success' | 'error'
|
|
||||||
}: {
|
|
||||||
message?: string;
|
|
||||||
type?: 'success' | 'error';
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let remaining = $state(5);
|
let remaining = $state(5);
|
||||||
let visible = $state(true);
|
let visible = $state(true);
|
||||||
@@ -30,17 +24,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
<div
|
<div role="alert">
|
||||||
role="alert"
|
<span>{message}</span>
|
||||||
class="fixed bottom-4 left-4 z-50 flex min-w-64 items-center gap-3 border px-4 py-3 text-sm shadow-lg {type ===
|
<span>{remaining}s</span>
|
||||||
'success'
|
<button type="button" onclick={dismiss}>×</button>
|
||||||
? 'border-green-600 bg-green-50 text-green-800'
|
|
||||||
: 'border-red-600 bg-red-50 text-red-800'}"
|
|
||||||
>
|
|
||||||
<span class="flex-1">{message}</span>
|
|
||||||
<span class="text-xs tabular-nums opacity-70">{remaining}s</span>
|
|
||||||
<button type="button" onclick={dismiss} class="ml-1 leading-none font-bold hover:opacity-70"
|
|
||||||
>×</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { Editor } from '@tiptap/core';
|
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
|
||||||
import Highlight from '@tiptap/extension-highlight';
|
|
||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
|
||||||
import { Color } from '@tiptap/extension-color';
|
|
||||||
|
|
||||||
let { initialContent = '', name = 'content' }: { initialContent?: string; name?: string } =
|
|
||||||
$props();
|
|
||||||
|
|
||||||
let element: HTMLElement;
|
|
||||||
let editor: Editor;
|
|
||||||
let content = $state('');
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
content = initialContent;
|
|
||||||
|
|
||||||
editor = new Editor({
|
|
||||||
element,
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
bulletList: false,
|
|
||||||
orderedList: false
|
|
||||||
}),
|
|
||||||
TextStyle,
|
|
||||||
Color,
|
|
||||||
Highlight.configure({ multicolor: true })
|
|
||||||
],
|
|
||||||
content: initialContent,
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class:
|
|
||||||
'prose prose-sm max-w-none outline-none h-full min-h-40 p-3 dark:prose-invert dark:text-gray-100'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate: ({ editor }) => {
|
|
||||||
content = editor.getHTML();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.setEditable(true);
|
|
||||||
|
|
||||||
return () => editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleBold() {
|
|
||||||
editor?.chain().focus().toggleBold().run();
|
|
||||||
}
|
|
||||||
function toggleItalic() {
|
|
||||||
editor?.chain().focus().toggleItalic().run();
|
|
||||||
}
|
|
||||||
function toggleHighlight() {
|
|
||||||
editor?.chain().focus().toggleHighlight().run();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setColor() {
|
|
||||||
const current = editor?.getAttributes('textStyle').color ?? '#000000';
|
|
||||||
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
|
|
||||||
const idx = colors.indexOf(current);
|
|
||||||
const next = colors[(idx + 1) % colors.length];
|
|
||||||
editor?.chain().focus().setColor(next).run();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="border border-gray-300 dark:border-gray-600">
|
|
||||||
<div
|
|
||||||
class="flex gap-1 border-b border-gray-300 bg-gray-100 p-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggleBold}
|
|
||||||
class="min-w-8 px-2 py-1 font-bold hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
||||||
title="Bold">B</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggleItalic}
|
|
||||||
class="min-w-8 px-2 py-1 italic hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
||||||
title="Italic">I</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggleHighlight}
|
|
||||||
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
||||||
title="Highlight"><span class="bg-yellow-200 px-1 dark:bg-yellow-600">H</span></button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={setColor}
|
|
||||||
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
||||||
title="Cycle text color">A</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
bind:this={element}
|
|
||||||
role="textbox"
|
|
||||||
tabindex="0"
|
|
||||||
contenteditable="true"
|
|
||||||
class="min-h-40 cursor-text bg-white dark:bg-gray-900"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" {name} value={content} />
|
|
||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
canonicalIdForFrequency,
|
canonicalIdForFrequency,
|
||||||
serializeCategory,
|
serializeCategory,
|
||||||
serializeColorPalette,
|
serializeColorPalette,
|
||||||
|
serializeGroup,
|
||||||
serializeLicense,
|
serializeLicense,
|
||||||
serializeProclamation,
|
serializeProclamation
|
||||||
serializeSubscriptionLevel
|
|
||||||
} from './v2-serializers';
|
} from './v2-serializers';
|
||||||
|
|
||||||
describe('canonicalIdForFrequency', () => {
|
describe('canonicalIdForFrequency', () => {
|
||||||
@@ -61,7 +61,7 @@ describe('serializeProclamation', () => {
|
|||||||
name: 'Peace & Relaxation',
|
name: 'Peace & Relaxation',
|
||||||
content: '<p>The text content, HTML formatted</p>',
|
content: '<p>The text content, HTML formatted</p>',
|
||||||
categoryId: 5,
|
categoryId: 5,
|
||||||
subscriptionLevelId: 1
|
groupId: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
it('derives solfeggioFrequencyId from the Hz value, not the DB id', () => {
|
it('derives solfeggioFrequencyId from the Hz value, not the DB id', () => {
|
||||||
@@ -84,13 +84,13 @@ describe('serializeProclamation', () => {
|
|||||||
expect(record).toBeNull();
|
expect(record).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes through a null subscriptionLevelId (kiosk treats as level 1)', () => {
|
it('passes through categoryId and groupId unchanged', () => {
|
||||||
const record = serializeProclamation({
|
const record = serializeProclamation({
|
||||||
...base,
|
...base,
|
||||||
subscriptionLevelId: null,
|
|
||||||
solfeggioFrequency: solarPlexus528
|
solfeggioFrequency: solarPlexus528
|
||||||
});
|
});
|
||||||
expect(record?.subscriptionLevelId).toBeNull();
|
expect(record?.categoryId).toBe(5);
|
||||||
|
expect(record?.groupId).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('embeds a 7-color palette when provided', () => {
|
it('embeds a 7-color palette when provided', () => {
|
||||||
@@ -116,64 +116,39 @@ describe('serializeProclamation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('serializeCategory', () => {
|
describe('serializeCategory', () => {
|
||||||
it('keeps parentId null for root categories', () => {
|
it('maps rows to a flat {id, name, sortOrder} with no parentId', () => {
|
||||||
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0, parentId: null })).toEqual({
|
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0 })).toEqual({
|
||||||
id: 4,
|
id: 4,
|
||||||
name: 'Spiritual',
|
name: 'Spiritual',
|
||||||
sortOrder: 0,
|
sortOrder: 0
|
||||||
parentId: null
|
});
|
||||||
|
expect(
|
||||||
|
Object.keys(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0 }))
|
||||||
|
).not.toContain('parentId');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps parentId int for children', () => {
|
describe('serializeGroup', () => {
|
||||||
expect(serializeCategory({ id: 5, name: 'General', sortOrder: 0, parentId: 4 })).toEqual({
|
it('maps rows to {id, name, sortOrder}', () => {
|
||||||
id: 5,
|
expect(serializeGroup({ id: 1, name: 'General', sortOrder: 0 })).toEqual({
|
||||||
|
id: 1,
|
||||||
name: 'General',
|
name: 'General',
|
||||||
sortOrder: 0,
|
sortOrder: 0
|
||||||
parentId: 4
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('serializeLicense', () => {
|
describe('serializeLicense', () => {
|
||||||
it('returns subscription_level as an object when the level row exists', () => {
|
it('returns only key and email_address (levels removed)', () => {
|
||||||
const result = serializeLicense({
|
const result = serializeLicense({
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
key: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
emailAddress: 'user@example.com',
|
emailAddress: 'user@example.com'
|
||||||
levelName: 'Advanced',
|
|
||||||
level: { id: 2, name: 'Advanced', description: 'General + Specific' }
|
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
key: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
email_address: 'user@example.com',
|
email_address: 'user@example.com'
|
||||||
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a structurally consistent object when no level row matches', () => {
|
|
||||||
const result = serializeLicense({
|
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
|
||||||
emailAddress: 'user@example.com',
|
|
||||||
levelName: 'SomeCustomLevel',
|
|
||||||
level: null
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
|
||||||
email_address: 'user@example.com',
|
|
||||||
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('serializeSubscriptionLevel', () => {
|
|
||||||
it('maps rows to {id, name, description}', () => {
|
|
||||||
expect(
|
|
||||||
serializeSubscriptionLevel({ id: 1, name: 'Basic', description: 'General only' })
|
|
||||||
).toEqual({
|
|
||||||
id: 1,
|
|
||||||
name: 'Basic',
|
|
||||||
description: 'General only'
|
|
||||||
});
|
});
|
||||||
|
expect(Object.keys(result)).not.toContain('subscription_level');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export type V2Proclamation = {
|
|||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
|
groupId: number;
|
||||||
solfeggioFrequencyId: number;
|
solfeggioFrequencyId: number;
|
||||||
subscriptionLevelId: number | null;
|
|
||||||
colorPalette?: string[];
|
colorPalette?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export type ProclamationWithFrequency = {
|
|||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
subscriptionLevelId: number | null;
|
groupId: number;
|
||||||
solfeggioFrequency: { value: number };
|
solfeggioFrequency: { value: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,8 +83,8 @@ export function serializeProclamation(
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
content: p.content,
|
content: p.content,
|
||||||
categoryId: p.categoryId,
|
categoryId: p.categoryId,
|
||||||
solfeggioFrequencyId,
|
groupId: p.groupId,
|
||||||
subscriptionLevelId: p.subscriptionLevelId
|
solfeggioFrequencyId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (palette && palette.length > 0) {
|
if (palette && palette.length > 0) {
|
||||||
@@ -98,53 +98,32 @@ export type V2Category = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
parentId: number | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeCategory(c: {
|
export function serializeCategory(c: { id: number; name: string; sortOrder: number }): V2Category {
|
||||||
|
return { id: c.id, name: c.name, sortOrder: c.sortOrder };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2Group = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
parentId: number | null;
|
|
||||||
}): V2Category {
|
|
||||||
return { id: c.id, name: c.name, sortOrder: c.sortOrder, parentId: c.parentId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type V2SubscriptionLevel = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeSubscriptionLevel(level: {
|
export function serializeGroup(g: { id: number; name: string; sortOrder: number }): V2Group {
|
||||||
id: number;
|
return { id: g.id, name: g.name, sortOrder: g.sortOrder };
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}): V2SubscriptionLevel {
|
|
||||||
return { id: level.id, name: level.name, description: level.description };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type V2License = {
|
export type V2License = {
|
||||||
key: string;
|
key: string;
|
||||||
email_address: string;
|
email_address: string;
|
||||||
subscription_level: { id: number | null; name: string; description: string | null };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build the POST /license response. `subscription_level` is always an object;
|
/** Build the POST /license response. */
|
||||||
* when no SubscriptionLevel row matches the license's level name, fall back to
|
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License {
|
||||||
* a structurally consistent object with a null id/description. */
|
|
||||||
export function serializeLicense(opts: {
|
|
||||||
key: string;
|
|
||||||
emailAddress: string;
|
|
||||||
levelName: string;
|
|
||||||
level: { id: number; name: string; description: string | null } | null;
|
|
||||||
}): V2License {
|
|
||||||
return {
|
return {
|
||||||
key: opts.key,
|
key: opts.key,
|
||||||
email_address: opts.emailAddress,
|
email_address: opts.emailAddress
|
||||||
subscription_level: opts.level
|
|
||||||
? { id: opts.level.id, name: opts.level.name, description: opts.level.description }
|
|
||||||
: { id: null, name: opts.levelName, description: null }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function signedRequest(opts: {
|
|||||||
const path = '/api/v2/automations/create/account';
|
const path = '/api/v2/automations/create/account';
|
||||||
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
|
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
|
||||||
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
|
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
|
||||||
const body = '{"email":"user@example.com","subscription_level":"Basic"}';
|
const body = '{"email":"user@example.com"}';
|
||||||
|
|
||||||
let ts = timestamp;
|
let ts = timestamp;
|
||||||
let nonceValue = nonce;
|
let nonceValue = nonce;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ vi.mock('$lib/db', () => {
|
|||||||
userRole: mkdel(),
|
userRole: mkdel(),
|
||||||
proclamation: mkdel(),
|
proclamation: mkdel(),
|
||||||
proclamationCategory: mkdel(),
|
proclamationCategory: mkdel(),
|
||||||
solfeggioFrequency: mkdel(),
|
proclamationGroup: mkdel(),
|
||||||
subscriptionLevel: mkdel()
|
solfeggioFrequency: mkdel()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const modelSchemas: Record<string, z.ZodObject<any>> = {
|
|||||||
UserRole: Schemas.UserRole,
|
UserRole: Schemas.UserRole,
|
||||||
Proclamation: Schemas.Proclamation,
|
Proclamation: Schemas.Proclamation,
|
||||||
ProclamationCategory: Schemas.ProclamationCategory,
|
ProclamationCategory: Schemas.ProclamationCategory,
|
||||||
|
ProclamationGroup: Schemas.ProclamationGroup,
|
||||||
SolfeggioFrequency: Schemas.SolfeggioFrequency,
|
SolfeggioFrequency: Schemas.SolfeggioFrequency,
|
||||||
SubscriptionLevel: Schemas.SubscriptionLevel,
|
|
||||||
LicenseKey: Schemas.LicenseKey
|
LicenseKey: Schemas.LicenseKey
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const models: AdminModelConfig[] = [
|
|||||||
displayNameSingular: 'User',
|
displayNameSingular: 'User',
|
||||||
listOnly: true,
|
listOnly: true,
|
||||||
idType: 'String',
|
idType: 'String',
|
||||||
include: { role: true, profile: true, licenseKeys: true },
|
include: { role: true, profiles: true, licenseKeys: true },
|
||||||
listFields: ['name', 'email', 'emailVerified', 'role.name', 'createdAt'],
|
listFields: ['name', 'email', 'emailVerified', 'role.name', 'createdAt'],
|
||||||
searchFields: ['name', 'email'],
|
searchFields: ['name', 'email'],
|
||||||
formFields: ['roleId'],
|
formFields: ['roleId'],
|
||||||
@@ -27,9 +27,9 @@ const models: AdminModelConfig[] = [
|
|||||||
},
|
},
|
||||||
relatedRecords: [
|
relatedRecords: [
|
||||||
{
|
{
|
||||||
relationKey: 'profile',
|
relationKey: 'profiles',
|
||||||
label: 'Profile',
|
label: 'Profiles',
|
||||||
type: 'has-one',
|
type: 'has-many',
|
||||||
displayFields: ['firstName', 'lastName', 'phoneNumber'],
|
displayFields: ['firstName', 'lastName', 'phoneNumber'],
|
||||||
editSlug: 'profiles'
|
editSlug: 'profiles'
|
||||||
},
|
},
|
||||||
@@ -37,7 +37,7 @@ const models: AdminModelConfig[] = [
|
|||||||
relationKey: 'licenseKeys',
|
relationKey: 'licenseKeys',
|
||||||
label: 'License Keys',
|
label: 'License Keys',
|
||||||
type: 'has-many',
|
type: 'has-many',
|
||||||
displayFields: ['key', 'active', 'level']
|
displayFields: ['key', 'active']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
accessFilter: (viewerRoleId: number) => {
|
accessFilter: (viewerRoleId: number) => {
|
||||||
@@ -55,12 +55,13 @@ const models: AdminModelConfig[] = [
|
|||||||
displayNameSingular: 'Profile',
|
displayNameSingular: 'Profile',
|
||||||
idType: 'String',
|
idType: 'String',
|
||||||
include: { user: true, addresses: true },
|
include: { user: true, addresses: true },
|
||||||
listFields: ['firstName', 'lastName', 'phoneNumber', 'user.name'],
|
listFields: ['firstName', 'lastName', 'email', 'phoneNumber', 'user.name'],
|
||||||
searchFields: ['firstName', 'lastName', 'phoneNumber'],
|
searchFields: ['firstName', 'lastName', 'email', 'phoneNumber'],
|
||||||
formFields: ['userId', 'firstName', 'lastName', 'phoneNumber'],
|
formFields: ['userId', 'email', 'firstName', 'lastName', 'phoneNumber'],
|
||||||
readonlyFields: ['id'],
|
readonlyFields: ['id'],
|
||||||
fieldTypes: {
|
fieldTypes: {
|
||||||
userId: 'String',
|
userId: 'String',
|
||||||
|
email: 'String',
|
||||||
firstName: 'String',
|
firstName: 'String',
|
||||||
lastName: 'String',
|
lastName: 'String',
|
||||||
phoneNumber: 'String'
|
phoneNumber: 'String'
|
||||||
@@ -97,31 +98,17 @@ const models: AdminModelConfig[] = [
|
|||||||
displayName: 'Proclamations',
|
displayName: 'Proclamations',
|
||||||
displayNameSingular: 'Proclamation',
|
displayNameSingular: 'Proclamation',
|
||||||
idType: 'String',
|
idType: 'String',
|
||||||
include: {
|
include: { solfeggioFrequency: true, category: true, group: true },
|
||||||
solfeggioFrequency: true,
|
listFields: ['name', 'category.name', 'group.name', 'solfeggioFrequency.name'],
|
||||||
category: { include: { parent: true } },
|
|
||||||
subscriptionLevel: true
|
|
||||||
},
|
|
||||||
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
|
|
||||||
searchFields: ['name', 'content'],
|
searchFields: ['name', 'content'],
|
||||||
formFields: [
|
formFields: ['name', 'solfeggioFrequencyId', 'categoryId', 'groupId', 'content'],
|
||||||
'name',
|
|
||||||
'solfeggioFrequencyId',
|
|
||||||
'parentCategoryId',
|
|
||||||
'categoryId',
|
|
||||||
'subscriptionLevelId',
|
|
||||||
'content'
|
|
||||||
],
|
|
||||||
readonlyFields: ['id'],
|
readonlyFields: ['id'],
|
||||||
dataOnlyFields: ['parentCategoryId'],
|
|
||||||
cascadeFields: { categoryId: { parentField: 'parentCategoryId' } },
|
|
||||||
fieldTypes: {
|
fieldTypes: {
|
||||||
name: 'String',
|
name: 'String',
|
||||||
content: 'Tiptap',
|
content: 'Tiptap',
|
||||||
solfeggioFrequencyId: 'Int',
|
solfeggioFrequencyId: 'Int',
|
||||||
parentCategoryId: 'Int',
|
|
||||||
categoryId: 'Int',
|
categoryId: 'Int',
|
||||||
subscriptionLevelId: 'Int'
|
groupId: 'Int'
|
||||||
},
|
},
|
||||||
relationOptions: {
|
relationOptions: {
|
||||||
solfeggioFrequencyId: {
|
solfeggioFrequencyId: {
|
||||||
@@ -129,18 +116,13 @@ const models: AdminModelConfig[] = [
|
|||||||
labelField: 'name',
|
labelField: 'name',
|
||||||
valueField: 'id'
|
valueField: 'id'
|
||||||
},
|
},
|
||||||
parentCategoryId: {
|
|
||||||
model: 'ProclamationCategory',
|
|
||||||
labelField: 'name',
|
|
||||||
valueField: 'id'
|
|
||||||
},
|
|
||||||
categoryId: {
|
categoryId: {
|
||||||
model: 'ProclamationCategory',
|
model: 'ProclamationCategory',
|
||||||
labelField: 'name',
|
labelField: 'name',
|
||||||
valueField: 'id'
|
valueField: 'id'
|
||||||
},
|
},
|
||||||
subscriptionLevelId: {
|
groupId: {
|
||||||
model: 'SubscriptionLevel',
|
model: 'ProclamationGroup',
|
||||||
labelField: 'name',
|
labelField: 'name',
|
||||||
valueField: 'id'
|
valueField: 'id'
|
||||||
}
|
}
|
||||||
@@ -172,19 +154,19 @@ const models: AdminModelConfig[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'subscription-levels',
|
slug: 'proclamation-categories',
|
||||||
modelName: 'SubscriptionLevel',
|
modelName: 'ProclamationCategory',
|
||||||
displayName: 'Subscription Levels',
|
displayName: 'Proclamation Categories',
|
||||||
displayNameSingular: 'Subscription Level',
|
displayNameSingular: 'Proclamation Category',
|
||||||
idType: 'Int',
|
idType: 'Int',
|
||||||
include: { proclamations: true },
|
include: { proclamations: true },
|
||||||
listFields: ['id', 'name', 'description'],
|
listFields: ['name'],
|
||||||
searchFields: ['name', 'description'],
|
searchFields: ['name'],
|
||||||
formFields: ['name', 'description'],
|
formFields: ['name'],
|
||||||
readonlyFields: ['id'],
|
readonlyFields: ['id'],
|
||||||
fieldTypes: {
|
fieldTypes: {
|
||||||
name: 'String',
|
name: 'String',
|
||||||
description: 'String'
|
sortOrder: 'Int'
|
||||||
},
|
},
|
||||||
relatedRecords: [
|
relatedRecords: [
|
||||||
{
|
{
|
||||||
@@ -197,24 +179,20 @@ const models: AdminModelConfig[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'proclamation-categories',
|
slug: 'proclamation-groups',
|
||||||
modelName: 'ProclamationCategory',
|
modelName: 'ProclamationGroup',
|
||||||
displayName: 'Proclamation Categories',
|
displayName: 'Proclamation Groups',
|
||||||
displayNameSingular: 'Proclamation Category',
|
displayNameSingular: 'Proclamation Group',
|
||||||
idType: 'Int',
|
idType: 'Int',
|
||||||
include: { proclamations: true, children: true },
|
include: { proclamations: true },
|
||||||
listFields: ['name'],
|
listFields: ['name', 'sortOrder'],
|
||||||
searchFields: ['name'],
|
searchFields: ['name'],
|
||||||
formFields: ['name', 'parentId'],
|
formFields: ['name'],
|
||||||
readonlyFields: ['id'],
|
readonlyFields: ['id'],
|
||||||
fieldTypes: {
|
fieldTypes: {
|
||||||
name: 'String',
|
name: 'String',
|
||||||
parentId: 'Int',
|
|
||||||
sortOrder: 'Int'
|
sortOrder: 'Int'
|
||||||
},
|
},
|
||||||
relationOptions: {
|
|
||||||
parentId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' }
|
|
||||||
},
|
|
||||||
relatedRecords: [
|
relatedRecords: [
|
||||||
{
|
{
|
||||||
relationKey: 'proclamations',
|
relationKey: 'proclamations',
|
||||||
@@ -222,13 +200,6 @@ const models: AdminModelConfig[] = [
|
|||||||
type: 'has-many',
|
type: 'has-many',
|
||||||
displayFields: ['name'],
|
displayFields: ['name'],
|
||||||
editSlug: 'proclamations'
|
editSlug: 'proclamations'
|
||||||
},
|
|
||||||
{
|
|
||||||
relationKey: 'children',
|
|
||||||
label: 'Sub-categories',
|
|
||||||
type: 'has-many',
|
|
||||||
displayFields: ['name'],
|
|
||||||
editSlug: 'proclamation-categories'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -239,15 +210,15 @@ const models: AdminModelConfig[] = [
|
|||||||
displayNameSingular: 'License Key',
|
displayNameSingular: 'License Key',
|
||||||
idType: 'String',
|
idType: 'String',
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
listFields: ['key', 'active', 'level', 'user.name'],
|
listFields: ['key', 'active', 'email', 'user.name'],
|
||||||
searchFields: ['key', 'level'],
|
searchFields: ['key', 'email'],
|
||||||
formFields: ['active', 'level', 'userId'],
|
formFields: ['active', 'userId', 'email'],
|
||||||
readonlyFields: ['id'],
|
readonlyFields: ['id'],
|
||||||
fieldTypes: {
|
fieldTypes: {
|
||||||
key: 'String',
|
key: 'String',
|
||||||
active: 'Boolean',
|
active: 'Boolean',
|
||||||
level: 'String',
|
userId: 'String',
|
||||||
userId: 'String'
|
email: 'String'
|
||||||
},
|
},
|
||||||
relationOptions: {
|
relationOptions: {
|
||||||
userId: { model: 'User', labelField: 'name', valueField: 'id' }
|
userId: { model: 'User', labelField: 'name', valueField: 'id' }
|
||||||
|
|||||||
@@ -17,11 +17,17 @@ export const User = z.object({
|
|||||||
roleId: z.coerce.number().int().min(1)
|
roleId: z.coerce.number().int().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Profile = z.object({
|
export const Profile = z
|
||||||
userId: z.string().min(1, 'Required'),
|
.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
email: z.string().email('Invalid email address').optional(),
|
||||||
firstName: name,
|
firstName: name,
|
||||||
lastName: name,
|
lastName: name,
|
||||||
phoneNumber: phone
|
phoneNumber: phone
|
||||||
|
})
|
||||||
|
.refine((p) => p.userId || p.email, {
|
||||||
|
message: 'Provide either a user or an email',
|
||||||
|
path: ['email']
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Address = z.object({
|
export const Address = z.object({
|
||||||
@@ -42,29 +48,31 @@ export const SolfeggioFrequency = z.object({
|
|||||||
value: z.coerce.number().int('Must be a whole number')
|
value: z.coerce.number().int('Must be a whole number')
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SubscriptionLevel = z.object({
|
export const ProclamationGroup = z.object({
|
||||||
name: z.string().min(1, 'Required'),
|
name: z.string().min(1, 'Required')
|
||||||
description: z.string().optional().default('')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProclamationCategory = z.object({
|
export const ProclamationCategory = z.object({
|
||||||
name: z.string().min(1, 'Required'),
|
name: z.string().min(1, 'Required')
|
||||||
parentId: z.coerce.number().int().optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Proclamation = z.object({
|
export const Proclamation = z.object({
|
||||||
name: z.string().min(1, 'Required'),
|
name: z.string().min(1, 'Required'),
|
||||||
solfeggioFrequencyId: z.coerce.number().int(),
|
solfeggioFrequencyId: z.coerce.number().int(),
|
||||||
parentCategoryId: z.coerce.number().int(),
|
categoryId: z.coerce.number().int(),
|
||||||
categoryId: z.coerce.number().int().optional(),
|
groupId: z.coerce.number().int(),
|
||||||
subscriptionLevelId: z.coerce.number().int(),
|
|
||||||
content: z.string().optional().default('')
|
content: z.string().optional().default('')
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LicenseKey = z.object({
|
export const LicenseKey = z
|
||||||
|
.object({
|
||||||
active: z.coerce.boolean(),
|
active: z.coerce.boolean(),
|
||||||
level: z.string().min(1, 'Required'),
|
userId: z.string().optional(),
|
||||||
userId: z.string().min(1, 'Required')
|
email: z.string().email('Invalid email address').optional()
|
||||||
|
})
|
||||||
|
.refine((k) => k.userId || k.email, {
|
||||||
|
message: 'Provide either a user or an email',
|
||||||
|
path: ['email']
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserUpdate = z.object({
|
export const UserUpdate = z.object({
|
||||||
|
|||||||
@@ -4,22 +4,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Window>
|
<Window>
|
||||||
<div class="flex flex-col items-center gap-4 p-6 text-center">
|
<div>
|
||||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<h1>
|
||||||
Admin Error ({$page.status})
|
Admin Error ({$page.status})
|
||||||
</h1>
|
</h1>
|
||||||
<p class="max-w-md text-sm text-gray-600 dark:text-gray-400">
|
<p>
|
||||||
{#if $page.error?.message}
|
{#if $page.error?.message}
|
||||||
{$page.error.message}
|
{$page.error.message}
|
||||||
{:else}
|
{:else}
|
||||||
An unexpected error occurred while loading this admin page.
|
An unexpected error occurred while loading this admin page.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a href="/admin"> Back to Admin </a>
|
||||||
href="/admin"
|
|
||||||
class="mt-2 border border-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Back to Admin
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -5,15 +5,12 @@
|
|||||||
let models = $derived(data.models);
|
let models = $derived(data.models);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1 class="p-6 text-xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
|
<h1>Admin Dashboard</h1>
|
||||||
<div class="grid grid-cols-1 gap-4 px-6 pb-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div>
|
||||||
{#each models as model (model.slug)}
|
{#each models as model (model.slug)}
|
||||||
<a
|
<a href={'/admin/' + model.slug}>
|
||||||
href={'/admin/' + model.slug}
|
<h2>{model.displayName}</h2>
|
||||||
class="block border border-gray-200 p-4 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
<p>
|
||||||
>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{model.displayName}</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{model.listFields.length} fields
|
{model.listFields.length} fields
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
let mode = $state<'normal' | 'emergency' | null>(null);
|
let mode = $state<'normal' | 'emergency' | null>(null);
|
||||||
let expiryDate = $state<string | null>(null);
|
let expiryDate = $state<string | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
|
|
||||||
function handleRotate(emergency: boolean) {
|
function handleRotate(emergency: boolean) {
|
||||||
return ({ cancel }: any) => {
|
return ({ cancel }: any) => {
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toastMessage = 'Rotation failed';
|
toastMessage = 'Rotation failed';
|
||||||
toastType = 'error';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -38,7 +36,6 @@
|
|||||||
function copyKey() {
|
function copyKey() {
|
||||||
navigator.clipboard.writeText(newKey);
|
navigator.clipboard.writeText(newKey);
|
||||||
toastMessage = 'Copied to clipboard';
|
toastMessage = 'Copied to clipboard';
|
||||||
toastType = 'success';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissKey() {
|
function dismissKey() {
|
||||||
@@ -48,44 +45,26 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
|
<h1>API Key Management</h1>
|
||||||
|
|
||||||
{#if newKey}
|
{#if newKey}
|
||||||
<div
|
<div>
|
||||||
class="mb-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
|
<p>
|
||||||
>
|
|
||||||
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
|
|
||||||
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
|
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-1 text-xs text-amber-700 dark:text-amber-400">
|
<p>This key will not be shown again. Copy it now.</p>
|
||||||
This key will not be shown again. Copy it now.
|
<div>
|
||||||
</p>
|
<code>
|
||||||
<div class="mb-3">
|
|
||||||
<code
|
|
||||||
class="rounded border border-amber-300 bg-white px-3 py-2 font-mono text-sm break-all dark:border-amber-600 dark:bg-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
{newKey}
|
{newKey}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div>
|
||||||
<button
|
<button type="button" onclick={copyKey}> Copy to Clipboard </button>
|
||||||
type="button"
|
<button type="button" onclick={dismissKey}> Dismiss </button>
|
||||||
onclick={copyKey}
|
|
||||||
class="border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-600 dark:bg-gray-800 dark:text-amber-300 dark:hover:bg-amber-900/50"
|
|
||||||
>
|
|
||||||
Copy to Clipboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={dismissKey}
|
|
||||||
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{#if mode === 'normal' && expiryDate}
|
{#if mode === 'normal' && expiryDate}
|
||||||
<p class="mt-3 text-xs text-amber-700 dark:text-amber-400">
|
<p>
|
||||||
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
|
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
|
||||||
to give in-field devices time to update.
|
to give in-field devices time to update.
|
||||||
</p>
|
</p>
|
||||||
@@ -94,23 +73,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<div
|
<div>
|
||||||
class="mb-4 border px-4 py-2 text-sm {toastType === 'success'
|
|
||||||
? 'border-green-300 bg-green-50 text-green-700'
|
|
||||||
: 'border-red-300 bg-red-50 text-red-700'}"
|
|
||||||
>
|
|
||||||
{toastMessage}
|
{toastMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-6 rounded border border-gray-200 p-4 dark:border-gray-700">
|
|
||||||
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Current Key Status</h2>
|
|
||||||
{#if data.activeKey}
|
|
||||||
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div>
|
<div>
|
||||||
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
|
<h2>Current Key Status</h2>
|
||||||
>{data.activeKey.prefix}</code
|
{#if data.activeKey}
|
||||||
>
|
<div>
|
||||||
|
<div>
|
||||||
|
Prefix: <code>{data.activeKey.prefix}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
|
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
|
||||||
{#if data.activeKey.expiresAt}
|
{#if data.activeKey.expiresAt}
|
||||||
@@ -118,31 +91,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
|
<p>No active API key. Generate one below.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.totalRotated > 0}
|
{#if data.totalRotated > 0}
|
||||||
<p class="mt-2 text-xs text-gray-400">
|
<p>
|
||||||
{data.totalRotated} previous key(s) have been rotated.
|
{data.totalRotated} previous key(s) have been rotated.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div>
|
||||||
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
|
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
|
||||||
<button
|
<button type="submit"> Generate New API Key </button>
|
||||||
type="submit"
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-2 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"
|
|
||||||
>
|
|
||||||
Generate New API Key
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
|
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
|
||||||
<button
|
<button type="submit"> Emergency Rotation </button>
|
||||||
type="submit"
|
|
||||||
class="border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
|
||||||
>
|
|
||||||
Emergency Rotation
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,18 +10,7 @@ const config = getModelConfig('license-keys')!;
|
|||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const records = await loadRecords(locals, config);
|
const records = await loadRecords(locals, config);
|
||||||
|
|
||||||
const subscriptionLevels = await prisma.subscriptionLevel.findMany({
|
return { ...records };
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...records,
|
|
||||||
subscriptionLevels: subscriptionLevels.map((s: { id: number; name: string }) => ({
|
|
||||||
label: s.name,
|
|
||||||
value: s.name
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -29,20 +18,13 @@ export const actions = {
|
|||||||
if (!locals.user) throw error(401, 'Unauthorized');
|
if (!locals.user) throw error(401, 'Unauthorized');
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const email = formData.get('email')?.toString();
|
const email = formData.get('email')?.toString()?.trim().toLowerCase();
|
||||||
const level = formData.get('level')?.toString();
|
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return { success: false, error: 'Email is required' };
|
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 } });
|
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 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)}`;
|
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: {
|
data: {
|
||||||
key,
|
key,
|
||||||
active: true,
|
active: true,
|
||||||
level,
|
userId: user?.id,
|
||||||
userId: user.id
|
email: user ? undefined : email
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Your Rhemawave License Key',
|
subject: 'Your Rhemawave License Key',
|
||||||
templateId: 'license-key-delivery',
|
templateId: 'license-key-delivery',
|
||||||
data: {
|
data: {
|
||||||
firstName: profile?.firstName ?? user.name ?? 'Valued Customer',
|
firstName: profile?.firstName ?? user?.name ?? 'Valued Customer',
|
||||||
license_key: key
|
license_key: key
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -92,8 +76,8 @@ export const actions = {
|
|||||||
data: {
|
data: {
|
||||||
key,
|
key,
|
||||||
active: true,
|
active: true,
|
||||||
level: existing.level,
|
userId: existing.userId,
|
||||||
userId: existing.userId
|
email: existing.email
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import LicenseKeyForm from './LicenseKeyForm.svelte';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
@@ -15,7 +14,9 @@
|
|||||||
let replaceOpen = $state(false);
|
let replaceOpen = $state(false);
|
||||||
let replaceRecord = $state<Record<string, unknown> | null>(null);
|
let replaceRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
|
let email = $state('');
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
createOpen = true;
|
createOpen = true;
|
||||||
@@ -40,26 +41,35 @@
|
|||||||
if (result.type === 'success' && result.status === 200) {
|
if (result.type === 'success' && result.status === 200) {
|
||||||
closeReplace();
|
closeReplace();
|
||||||
toastMessage = 'License key replaced';
|
toastMessage = 'License key replaced';
|
||||||
toastType = 'success';
|
|
||||||
} else {
|
} else {
|
||||||
toastMessage = 'Failed to replace license key';
|
toastMessage = 'Failed to replace license key';
|
||||||
toastType = 'error';
|
|
||||||
}
|
}
|
||||||
await update();
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div>
|
||||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<button
|
<button type="button" onclick={openCreate}>New License Key +</button>
|
||||||
type="button"
|
|
||||||
onclick={openCreate}
|
|
||||||
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
New License Key +
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminTable
|
<AdminTable
|
||||||
@@ -74,58 +84,45 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
|
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
|
||||||
<LicenseKeyForm subscriptionLevels={data.subscriptionLevels} onSuccess={closeCreate} />
|
<form method="POST" action="?/createKey" use:enhance={createEnhance}>
|
||||||
|
{#if formError}
|
||||||
|
<div>{formError}</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<label for="field-email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="field-email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
bind:value={email}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
<p>If no account exists for this email yet, the key will be attached when they register.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Create & Email Key</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
|
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
|
||||||
{#if replaceRecord}
|
{#if replaceRecord}
|
||||||
<div class="flex flex-col gap-4">
|
<div>
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p>Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?</p>
|
||||||
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
|
<div>
|
||||||
</p>
|
<div>User: <span>{String(replaceRecord['user.name'] ?? '')}</span></div>
|
||||||
<div
|
|
||||||
class="rounded border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<div class="mb-1 text-gray-500 dark:text-gray-400">
|
|
||||||
User: <span class="font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>{String(replaceRecord['user.name'] ?? '')}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-500 dark:text-gray-400">
|
<p>The existing key will be deleted and a new one will be created and emailed.</p>
|
||||||
Level: <span class="font-medium text-gray-700 dark:text-gray-300"
|
<form method="POST" action="?/replace" use:enhance={replaceEnhance}>
|
||||||
>{String(replaceRecord.level ?? '')}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
The existing key will be deleted and a new one with the same subscription level will be
|
|
||||||
created and emailed.
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/replace"
|
|
||||||
use:enhance={replaceEnhance}
|
|
||||||
class="flex justify-end gap-2 pt-2"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={String(replaceRecord.id)} />
|
<input type="hidden" name="id" value={String(replaceRecord.id)} />
|
||||||
<button
|
<button type="button" onclick={closeReplace}>Cancel</button>
|
||||||
type="button"
|
<button type="submit">Generate Replacement</button>
|
||||||
onclick={closeReplace}
|
|
||||||
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="border border-amber-300 bg-amber-50 px-4 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50"
|
|
||||||
>
|
|
||||||
Generate Replacement
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
|
|
||||||
let {
|
|
||||||
subscriptionLevels = [] as { label: string; value: string }[],
|
|
||||||
onSuccess
|
|
||||||
}: {
|
|
||||||
subscriptionLevels?: { label: string; value: string }[];
|
|
||||||
onSuccess?: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let email = $state('');
|
|
||||||
let level = $state('');
|
|
||||||
let formError = $state('');
|
|
||||||
|
|
||||||
function handleEnhance() {
|
|
||||||
return async ({ result, update }: any) => {
|
|
||||||
if (result.type === 'success' && result.status === 200) {
|
|
||||||
if (result.data?.error) {
|
|
||||||
formError = result.data.error;
|
|
||||||
} else {
|
|
||||||
email = '';
|
|
||||||
level = '';
|
|
||||||
formError = '';
|
|
||||||
await update();
|
|
||||||
onSuccess?.();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await update();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form method="POST" action="?/createKey" use:enhance={handleEnhance}>
|
|
||||||
{#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-email"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
User Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="field-email"
|
|
||||||
name="email"
|
|
||||||
required
|
|
||||||
bind:value={email}
|
|
||||||
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
placeholder="user@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-level"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Subscription Level
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="level"
|
|
||||||
id="field-level"
|
|
||||||
required
|
|
||||||
onchange={(e) => (level = (e.target as HTMLSelectElement).value)}
|
|
||||||
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="" disabled selected={!level}>-- Select --</option>
|
|
||||||
{#each subscriptionLevels as opt (opt.value)}
|
|
||||||
<option value={opt.value} selected={opt.value === level}>{opt.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
Create & Email Key
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -19,9 +19,8 @@ vi.mock('$lib/svelte-admin/crud', () => ({
|
|||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
user: { findUnique: vi.fn() },
|
user: { findUnique: vi.fn() },
|
||||||
profile: { findUnique: vi.fn() },
|
profile: { findFirst: vi.fn() },
|
||||||
licenseKey: { create: vi.fn() },
|
licenseKey: { create: vi.fn() }
|
||||||
subscriptionLevel: { findMany: vi.fn() }
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ import { prisma } from '$lib/db';
|
|||||||
import { actions } from './+page.server';
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
const mockFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
const mockFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
|
const mockProfileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
|
||||||
const mockLicenseKeyCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
|
const mockLicenseKeyCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
describe('admin license-keys createKey action', () => {
|
describe('admin license-keys createKey action', () => {
|
||||||
@@ -39,7 +38,7 @@ describe('admin license-keys createKey action', () => {
|
|||||||
|
|
||||||
it('rejects unauthenticated requests', async () => {
|
it('rejects unauthenticated requests', async () => {
|
||||||
const locals = createLocals();
|
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);
|
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
|
||||||
expect(isHttpError(err, 401)).toBe(true);
|
expect(isHttpError(err, 401)).toBe(true);
|
||||||
@@ -47,27 +46,36 @@ describe('admin license-keys createKey action', () => {
|
|||||||
|
|
||||||
it('returns error when email is missing', async () => {
|
it('returns error when email is missing', async () => {
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser() as any);
|
||||||
const req = createFormRequest({ level: 'standard' });
|
const req = createFormRequest({});
|
||||||
|
|
||||||
const result = await actions.createKey({ request: req, locals } as any);
|
const result = await actions.createKey({ request: req, locals } as any);
|
||||||
expect(result).toEqual({ success: false, error: 'Email is required' });
|
expect(result).toEqual({ success: false, error: 'Email is required' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when level is missing', async () => {
|
it('creates a key with only an email when no user exists yet', 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 () => {
|
|
||||||
mockFindUnique.mockResolvedValue(null);
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
mockProfileFindFirst.mockResolvedValue(null);
|
||||||
|
mockLicenseKeyCreate.mockResolvedValue({});
|
||||||
|
sendMail.mockResolvedValue({ id: 'email-1' });
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
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);
|
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 () => {
|
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' };
|
const profile = { firstName: 'Jane' };
|
||||||
|
|
||||||
mockFindUnique.mockResolvedValue(user);
|
mockFindUnique.mockResolvedValue(user);
|
||||||
mockProfileFindUnique.mockResolvedValue(profile);
|
mockProfileFindFirst.mockResolvedValue(profile);
|
||||||
mockLicenseKeyCreate.mockResolvedValue({});
|
mockLicenseKeyCreate.mockResolvedValue({});
|
||||||
sendMail.mockResolvedValue({ id: 'email-1' });
|
sendMail.mockResolvedValue({ id: 'email-1' });
|
||||||
|
|
||||||
const locals = createLocals(user as any);
|
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);
|
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];
|
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.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.active).toBe(true);
|
||||||
expect(createCall.data.level).toBe('standard');
|
|
||||||
expect(createCall.data.userId).toBe('user-1');
|
expect(createCall.data.userId).toBe('user-1');
|
||||||
|
|
||||||
expect(sendMail).toHaveBeenCalledOnce();
|
expect(sendMail).toHaveBeenCalledOnce();
|
||||||
@@ -106,12 +113,12 @@ describe('admin license-keys createKey action', () => {
|
|||||||
const user = mockUser();
|
const user = mockUser();
|
||||||
|
|
||||||
mockFindUnique.mockResolvedValue(user);
|
mockFindUnique.mockResolvedValue(user);
|
||||||
mockProfileFindUnique.mockResolvedValue(null);
|
mockProfileFindFirst.mockResolvedValue(null);
|
||||||
mockLicenseKeyCreate.mockResolvedValue({});
|
mockLicenseKeyCreate.mockResolvedValue({});
|
||||||
sendMail.mockResolvedValue({ id: 'email-1' });
|
sendMail.mockResolvedValue({ id: 'email-1' });
|
||||||
|
|
||||||
const locals = createLocals(user as any);
|
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);
|
await actions.createKey({ request: req, locals } as any);
|
||||||
|
|
||||||
@@ -125,14 +132,14 @@ describe('admin license-keys createKey action', () => {
|
|||||||
const user = mockUser();
|
const user = mockUser();
|
||||||
|
|
||||||
mockFindUnique.mockResolvedValue(user);
|
mockFindUnique.mockResolvedValue(user);
|
||||||
mockProfileFindUnique.mockResolvedValue({ firstName: 'Jane' });
|
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
|
||||||
mockLicenseKeyCreate.mockResolvedValue({});
|
mockLicenseKeyCreate.mockResolvedValue({});
|
||||||
sendMail.mockRejectedValue(new Error('Resend API down'));
|
sendMail.mockRejectedValue(new Error('Resend API down'));
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
const locals = createLocals(user as any);
|
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);
|
const result = await actions.createKey({ request: req, locals } as any);
|
||||||
expect(result).toEqual({ success: true, error: undefined });
|
expect(result).toEqual({ success: true, error: undefined });
|
||||||
|
|||||||
@@ -4,51 +4,25 @@ import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
|
|||||||
|
|
||||||
const config = getModelConfig('proclamation-categories')!;
|
const config = getModelConfig('proclamation-categories')!;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => {
|
||||||
const delegate = getDelegate(config);
|
const delegate = getDelegate(config);
|
||||||
|
|
||||||
const records = await delegate.findMany({
|
const records = await delegate.findMany({
|
||||||
where: { parentId: null },
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
|
||||||
include: config.include,
|
|
||||||
orderBy: { sortOrder: 'asc' }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const relationOptionsData: Record<string, { label: string; value: 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
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
records: records as Record<string, unknown>[],
|
records: records as Record<string, unknown>[],
|
||||||
relationOptionsData
|
relationOptionsData: {}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
upsert: async ({ request, locals }) => {
|
upsert: async ({ request }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
const name = (formData.get('name')?.toString() ?? '').trim();
|
const name = (formData.get('name')?.toString() ?? '').trim();
|
||||||
const rawId = formData.get('id')?.toString();
|
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) {
|
if (!name) {
|
||||||
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
|
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
|
||||||
@@ -57,70 +31,31 @@ export const actions = {
|
|||||||
const delegate = getDelegate(config);
|
const delegate = getDelegate(config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let categoryId: number;
|
|
||||||
if (rawId) {
|
if (rawId) {
|
||||||
const id = parseInt(rawId, 10);
|
|
||||||
await delegate.update({
|
await delegate.update({
|
||||||
where: { id },
|
where: { id: parseInt(rawId, 10) },
|
||||||
data: { name }
|
data: { name }
|
||||||
});
|
});
|
||||||
categoryId = id;
|
|
||||||
} else {
|
} else {
|
||||||
const maxSort = await delegate.findFirst({
|
const maxSort = await delegate.findFirst({
|
||||||
where: { parentId: null },
|
|
||||||
orderBy: { sortOrder: 'desc' },
|
orderBy: { sortOrder: 'desc' },
|
||||||
select: { sortOrder: true }
|
select: { sortOrder: true }
|
||||||
});
|
});
|
||||||
const sortOrder = (maxSort?.sortOrder ?? -1) + 1;
|
|
||||||
const created = await delegate.create({
|
|
||||||
data: { name, sortOrder }
|
|
||||||
});
|
|
||||||
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<number>();
|
|
||||||
|
|
||||||
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({
|
await delegate.create({
|
||||||
data: { name: childName, parentId: categoryId }
|
data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of existingChildren) {
|
|
||||||
if (!submittedIdSet.has(child.id)) {
|
|
||||||
await delegate.delete({ where: { id: child.id } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'A sub-category with this name already exists under this parent.'
|
error: 'A category with this name already exists.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
delete: async ({ request, locals }) => {
|
delete: async ({ request, locals }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
@@ -137,8 +72,17 @@ export const actions = {
|
|||||||
if (!match) return { success: false, error: 'Access denied' };
|
if (!match) return { success: false, error: 'Access denied' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await delegate.deleteMany({ where: { parentId: id } });
|
try {
|
||||||
await delegate.delete({ where: { id } });
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
import CategoryForm from './CategoryForm.svelte';
|
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
@@ -16,16 +15,17 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form && 'success' in (form as any)) {
|
if (form && 'success' in (form as any)) {
|
||||||
const result = form as any;
|
const result = form as any;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toastMessage = 'Saved successfully';
|
toastMessage = 'Saved successfully';
|
||||||
toastType = 'success';
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
formError = result.error ?? '';
|
formError = result.error ?? '';
|
||||||
@@ -34,6 +34,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = String((editingRecord ?? {})['name'] ?? '');
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleReorder(ids: (string | number)[]) {
|
async function handleReorder(ids: (string | number)[]) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set('ids', JSON.stringify(ids));
|
formData.set('ids', JSON.stringify(ids));
|
||||||
@@ -61,10 +66,14 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (editingRecord?.id) dirty = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
@@ -87,14 +96,33 @@
|
|||||||
{canDelete}
|
{canDelete}
|
||||||
recordId={editingRecord?.id as string | undefined}
|
recordId={editingRecord?.id as string | undefined}
|
||||||
>
|
>
|
||||||
<CategoryForm
|
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
|
||||||
record={editingRecord ?? {}}
|
{#if editingRecord?.id}
|
||||||
{fieldErrors}
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
{formError}
|
{/if}
|
||||||
submitLabel={editingRecord ? 'Save' : 'Create'}
|
|
||||||
/>
|
{#if formError}
|
||||||
|
<div>{formError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label for="field-name">Name</label>
|
||||||
|
<input type="text" id="field-name" name="name" bind:value={name} />
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p>{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
|
||||||
|
{editingRecord ? 'Save' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
|
|
||||||
let {
|
|
||||||
record = {} as Record<string, unknown>,
|
|
||||||
fieldErrors = {} as Record<string, string>,
|
|
||||||
formError = '',
|
|
||||||
action = '?/upsert',
|
|
||||||
submitLabel = 'Save'
|
|
||||||
}: {
|
|
||||||
record?: Record<string, unknown>;
|
|
||||||
fieldErrors?: Record<string, string>;
|
|
||||||
formError?: string;
|
|
||||||
action?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let name = $state('');
|
|
||||||
let children = $state<{ id: number | null; name: string }[]>([]);
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
name = String(record.name ?? '');
|
|
||||||
const existingChildren = (record.children as Array<{ id: number; name: string }>) ?? [];
|
|
||||||
children = existingChildren.map((c) => ({ id: c.id, name: c.name }));
|
|
||||||
});
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addChild() {
|
|
||||||
children = [...children, { id: null, name: '' }];
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeChild(index: number) {
|
|
||||||
children = children.filter((_, i) => i !== index);
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form {action} method="POST" use:enhance oninput={markDirty}>
|
|
||||||
{#if record?.id}
|
|
||||||
<input type="hidden" name="id" value={String(record.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-name"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="field-name"
|
|
||||||
name="name"
|
|
||||||
bind:value={name}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.name}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Sub-categories
|
|
||||||
</label>
|
|
||||||
{#each children as child, i}
|
|
||||||
<div class="mb-2 flex items-center gap-2">
|
|
||||||
<input type="hidden" name="childId" value={child.id ?? ''} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="childName"
|
|
||||||
bind:value={child.name}
|
|
||||||
placeholder="Sub-category name"
|
|
||||||
class="flex-1 border px-3 py-1.5 text-sm {fieldErrors[`childName.${i}`]
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => removeChild(i)}
|
|
||||||
class="shrink-0 px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={addChild}
|
|
||||||
class="mt-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
||||||
>
|
|
||||||
+ Add Sub-category
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!!record?.id && !dirty}
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -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<string, unknown>[],
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
+52
-14
@@ -1,30 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import LevelForm from './LevelForm.svelte';
|
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
const config = getModelConfig('subscription-levels')!;
|
const config = getModelConfig('proclamation-groups')!;
|
||||||
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form && 'success' in (form as any)) {
|
if (form && 'success' in (form as any)) {
|
||||||
const result = form as any;
|
const result = form as any;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toastMessage = 'Saved successfully';
|
toastMessage = 'Saved successfully';
|
||||||
toastType = 'success';
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
formError = result.error ?? '';
|
formError = result.error ?? '';
|
||||||
@@ -33,6 +34,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = String((editingRecord ?? {})['name'] ?? '');
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleReorder(ids: (string | number)[]) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('ids', JSON.stringify(ids));
|
||||||
|
await fetch('?/reorder', { method: 'POST', body: formData });
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
@@ -53,16 +66,22 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (editingRecord?.id) dirty = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
searchFields={config.searchFields}
|
searchFields={config.searchFields}
|
||||||
fieldTypes={config.fieldTypes}
|
fieldTypes={config.fieldTypes}
|
||||||
singular={config.displayNameSingular}
|
singular={config.displayNameSingular}
|
||||||
|
draggable={true}
|
||||||
|
onReorder={handleReorder}
|
||||||
onRowClick={openEdit}
|
onRowClick={openEdit}
|
||||||
onNew={openCreate}
|
onNew={openCreate}
|
||||||
/>
|
/>
|
||||||
@@ -77,14 +96,33 @@
|
|||||||
{canDelete}
|
{canDelete}
|
||||||
recordId={editingRecord?.id as string | undefined}
|
recordId={editingRecord?.id as string | undefined}
|
||||||
>
|
>
|
||||||
<LevelForm
|
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
|
||||||
record={editingRecord ?? {}}
|
{#if editingRecord?.id}
|
||||||
{fieldErrors}
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
{formError}
|
{/if}
|
||||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
|
||||||
/>
|
{#if formError}
|
||||||
|
<div>{formError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label for="field-name">Name</label>
|
||||||
|
<input type="text" id="field-name" name="name" bind:value={name} />
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p>{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
|
||||||
|
{editingRecord ? 'Save' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -27,11 +27,16 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categories = await prisma.proclamationCategory.findMany({
|
const categories = await prisma.proclamationCategory.findMany({
|
||||||
select: { id: true, name: true, parentId: true },
|
select: { id: true, name: true },
|
||||||
orderBy: { name: 'asc' }
|
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 = {
|
export const actions = {
|
||||||
@@ -54,12 +59,8 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawId = formData.get('id')?.toString();
|
const rawId = formData.get('id')?.toString();
|
||||||
const { parentCategoryId, ...rest } = form.data;
|
|
||||||
|
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = { ...form.data };
|
||||||
...rest,
|
|
||||||
categoryId: rest.categoryId ?? parentCategoryId
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (rawId) {
|
if (rawId) {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
import { enhance } from '$app/forms';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
|
import { Color } from '@tiptap/extension-color';
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import ProclamationForm from './ProclamationForm.svelte';
|
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
@@ -17,16 +21,25 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
|
||||||
|
let dirty = $state(false);
|
||||||
|
let name = $state('');
|
||||||
|
let solfeggioFrequencyId = $state('');
|
||||||
|
let categoryId = $state('');
|
||||||
|
let groupId = $state('');
|
||||||
|
|
||||||
|
// Inline TipTap editor
|
||||||
|
let element = $state<HTMLElement>();
|
||||||
|
let editor: Editor | null = null;
|
||||||
|
let content = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form && 'success' in (form as any)) {
|
if (form && 'success' in (form as any)) {
|
||||||
const result = form as any;
|
const result = form as any;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toastMessage = 'Saved successfully';
|
toastMessage = 'Saved successfully';
|
||||||
toastType = 'success';
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
formError = result.error ?? '';
|
formError = result.error ?? '';
|
||||||
@@ -35,6 +48,80 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const record = editingRecord ?? {};
|
||||||
|
name = String(record['name'] ?? '');
|
||||||
|
solfeggioFrequencyId = String(record['solfeggioFrequencyId'] ?? '');
|
||||||
|
categoryId = String(record['categoryId'] ?? '');
|
||||||
|
groupId = String(record['groupId'] ?? '');
|
||||||
|
content = typeof record['content'] === 'string' ? (record['content'] as string) : '';
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (editingRecord?.id) dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryChange(e: Event) {
|
||||||
|
categoryId = (e.target as HTMLSelectElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGroupChange(e: Event) {
|
||||||
|
groupId = (e.target as HTMLSelectElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentKey = $derived(editingRecord?.id ?? 'new');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isSuper || !modalOpen || !element) return;
|
||||||
|
const initialContent =
|
||||||
|
typeof (editingRecord ?? {})['content'] === 'string'
|
||||||
|
? ((editingRecord ?? {})['content'] as string)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
editor = new Editor({
|
||||||
|
element,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({ multicolor: true })
|
||||||
|
],
|
||||||
|
content: initialContent,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
content = editor.getHTML();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const instance = editor;
|
||||||
|
instance.setEditable(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
instance.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleBold() {
|
||||||
|
editor?.chain().focus().toggleBold().run();
|
||||||
|
}
|
||||||
|
function toggleItalic() {
|
||||||
|
editor?.chain().focus().toggleItalic().run();
|
||||||
|
}
|
||||||
|
function toggleHighlight() {
|
||||||
|
editor?.chain().focus().toggleHighlight().run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setColor() {
|
||||||
|
const current = editor?.getAttributes('textStyle').color ?? '#000000';
|
||||||
|
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
|
||||||
|
const idx = colors.indexOf(current);
|
||||||
|
const next = colors[(idx + 1) % colors.length];
|
||||||
|
editor?.chain().focus().setColor(next).run();
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
@@ -57,8 +144,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
@@ -79,17 +166,109 @@
|
|||||||
{canDelete}
|
{canDelete}
|
||||||
recordId={editingRecord?.id as string | undefined}
|
recordId={editingRecord?.id as string | undefined}
|
||||||
>
|
>
|
||||||
<ProclamationForm
|
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
|
||||||
record={editingRecord ?? {}}
|
{#if editingRecord?.id}
|
||||||
relationOptionsData={data.relationOptionsData}
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
categories={data.categories}
|
{/if}
|
||||||
{fieldErrors}
|
|
||||||
{formError}
|
{#if formError}
|
||||||
{isSuper}
|
<div>{formError}</div>
|
||||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
{/if}
|
||||||
/>
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label for="field-name">Name</label>
|
||||||
|
<input type="text" id="field-name" name="name" bind:value={name} />
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p>{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="field-solfeggioFrequencyId">Frequency</label>
|
||||||
|
<select
|
||||||
|
id="field-solfeggioFrequencyId"
|
||||||
|
name="solfeggioFrequencyId"
|
||||||
|
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
<option value="">-- Select Frequency --</option>
|
||||||
|
{#each data.relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
|
||||||
|
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
|
||||||
|
>{opt.label}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.solfeggioFrequencyId}
|
||||||
|
<p>{fieldErrors.solfeggioFrequencyId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="field-categoryId">Category</label>
|
||||||
|
<select id="field-categoryId" name="categoryId" onchange={handleCategoryChange}>
|
||||||
|
<option value="">-- Select Category --</option>
|
||||||
|
{#each data.categories as cat (String(cat.id))}
|
||||||
|
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.categoryId}
|
||||||
|
<p>{fieldErrors.categoryId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="field-groupId">Group</label>
|
||||||
|
<select id="field-groupId" name="groupId" onchange={handleGroupChange}>
|
||||||
|
<option value="">-- Select Group --</option>
|
||||||
|
{#each data.groups as group (String(group.id))}
|
||||||
|
<option value={group.id} selected={String(group.id) === groupId}>{group.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.groupId}
|
||||||
|
<p>{fieldErrors.groupId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isSuper}
|
||||||
|
<div>
|
||||||
|
<label for="field-content">Content</label>
|
||||||
|
<div id="field-content">
|
||||||
|
{#key contentKey}
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<button type="button" onclick={toggleBold} title="Bold">B</button>
|
||||||
|
<button type="button" onclick={toggleItalic} title="Italic">I</button>
|
||||||
|
<button type="button" onclick={toggleHighlight} title="Highlight">
|
||||||
|
<span>H</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={setColor} title="Cycle text color">A</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
data-testid="tiptap-editor"
|
||||||
|
role="textbox"
|
||||||
|
tabindex="0"
|
||||||
|
contenteditable="true"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="content" value={content} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{#if fieldErrors.content}
|
||||||
|
<p>{fieldErrors.content}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
|
||||||
|
{editingRecord ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import TipTap from '$lib/components/tiptap/TipTap.svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
record = {} as Record<string, unknown>,
|
|
||||||
fieldErrors = {} as Record<string, string>,
|
|
||||||
formError = '',
|
|
||||||
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
|
|
||||||
categories = [] as { id: number; name: string; parentId: number | null }[],
|
|
||||||
isSuper = false,
|
|
||||||
action = '?/upsert',
|
|
||||||
submitLabel = 'Save'
|
|
||||||
}: {
|
|
||||||
record?: Record<string, unknown>;
|
|
||||||
fieldErrors?: Record<string, string>;
|
|
||||||
formError?: string;
|
|
||||||
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
|
|
||||||
categories?: { id: number; name: string; parentId: number | null }[];
|
|
||||||
isSuper?: boolean;
|
|
||||||
action?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = $state('');
|
|
||||||
let solfeggioFrequencyId = $state('');
|
|
||||||
let subscriptionLevelId = $state('');
|
|
||||||
let parentCategoryId = $state('');
|
|
||||||
let categoryId = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
name = String(record.name ?? '');
|
|
||||||
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
|
|
||||||
subscriptionLevelId = String(record.subscriptionLevelId ?? '');
|
|
||||||
parentCategoryId = String(
|
|
||||||
record.parentCategoryId ??
|
|
||||||
(record.category as Record<string, unknown> | undefined)?.parentId ??
|
|
||||||
''
|
|
||||||
);
|
|
||||||
categoryId = String(record.categoryId ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
let parentOptions = $derived(categories.filter((c) => c.parentId === null));
|
|
||||||
|
|
||||||
let childOptions = $derived(
|
|
||||||
parentCategoryId ? categories.filter((c) => c.parentId === Number(parentCategoryId)) : []
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleParentChange(e: Event) {
|
|
||||||
const target = e.target as HTMLSelectElement;
|
|
||||||
parentCategoryId = target.value;
|
|
||||||
categoryId = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentKey = $derived(record.id ?? 'new');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form {action} method="POST" use:enhance oninput={markDirty}>
|
|
||||||
{#if record?.id}
|
|
||||||
<input type="hidden" name="id" value={String(record.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-name"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="field-name"
|
|
||||||
name="name"
|
|
||||||
bind:value={name}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.name}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-solfeggioFrequencyId"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Frequency
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="field-solfeggioFrequencyId"
|
|
||||||
name="solfeggioFrequencyId"
|
|
||||||
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Frequency --</option>
|
|
||||||
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
|
|
||||||
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
|
|
||||||
>{opt.label}</option
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{#if fieldErrors.solfeggioFrequencyId}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-parentCategoryId"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Category
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="field-parentCategoryId"
|
|
||||||
name="parentCategoryId"
|
|
||||||
onchange={handleParentChange}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentCategoryId
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Category --</option>
|
|
||||||
{#each parentOptions as cat (String(cat.id))}
|
|
||||||
<option value={cat.id} selected={String(cat.id) === parentCategoryId}>{cat.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{#if fieldErrors.parentCategoryId}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentCategoryId}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-categoryId"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Sub-Category
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="field-categoryId"
|
|
||||||
name="categoryId"
|
|
||||||
onchange={(e) => (categoryId = (e.target as HTMLSelectElement).value)}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Sub-Category --</option>
|
|
||||||
{#each childOptions as cat (String(cat.id))}
|
|
||||||
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{#if fieldErrors.categoryId}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.categoryId}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-subscriptionLevelId"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Subscription Level
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="field-subscriptionLevelId"
|
|
||||||
name="subscriptionLevelId"
|
|
||||||
onchange={(e) => (subscriptionLevelId = (e.target as HTMLSelectElement).value)}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Level --</option>
|
|
||||||
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
|
|
||||||
<option value={opt.value} selected={String(opt.value) === subscriptionLevelId}
|
|
||||||
>{opt.label}</option
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{#if fieldErrors.subscriptionLevelId}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.subscriptionLevelId}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isSuper}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-content"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Content
|
|
||||||
</label>
|
|
||||||
<div id="field-content">
|
|
||||||
{#key record.id}
|
|
||||||
<TipTap
|
|
||||||
initialContent={typeof record.content === 'string' ? record.content : ''}
|
|
||||||
name="content"
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
{#if fieldErrors.content}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!!record?.id && !dirty}
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -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: '<p>Secret</p>' },
|
|
||||||
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: '<p>Secret</p>' },
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,6 +13,9 @@ vi.mock('$lib/db', () => ({
|
|||||||
},
|
},
|
||||||
proclamationCategory: {
|
proclamationCategory: {
|
||||||
findMany: vi.fn()
|
findMany: vi.fn()
|
||||||
|
},
|
||||||
|
proclamationGroup: {
|
||||||
|
findMany: vi.fn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -31,6 +34,7 @@ const mockLoadRecords = loadRecords as ReturnType<typeof vi.fn>;
|
|||||||
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
|
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
|
||||||
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
|
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
|
||||||
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
|
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
|
||||||
|
const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
|
||||||
const mockFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
|
const mockFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const mockCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
|
const mockCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
|
||||||
const mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
|
const mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
|
||||||
@@ -46,11 +50,13 @@ describe('proclamations admin page server', () => {
|
|||||||
{ id: '2', name: 'B', content: '<p>secret2</p>' }
|
{ id: '2', name: 'B', content: '<p>secret2</p>' }
|
||||||
];
|
];
|
||||||
const relationOptionsData = {};
|
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 () => {
|
it('strips content for non-Super users', async () => {
|
||||||
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
|
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
|
||||||
mockCatFindMany.mockResolvedValue(categories);
|
mockCatFindMany.mockResolvedValue(categories);
|
||||||
|
mockGroupFindMany.mockResolvedValue(groups);
|
||||||
|
|
||||||
const locals = createLocals(mockUser({ roleId: 4 }) as any);
|
const locals = createLocals(mockUser({ roleId: 4 }) as any);
|
||||||
const result = await load({ locals } as any);
|
const result = await load({ locals } as any);
|
||||||
@@ -61,13 +67,15 @@ describe('proclamations admin page server', () => {
|
|||||||
{ id: '2', name: 'B' }
|
{ id: '2', name: 'B' }
|
||||||
],
|
],
|
||||||
relationOptionsData,
|
relationOptionsData,
|
||||||
categories
|
categories,
|
||||||
|
groups
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps content for Super users', async () => {
|
it('keeps content for Super users', async () => {
|
||||||
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
|
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
|
||||||
mockCatFindMany.mockResolvedValue(categories);
|
mockCatFindMany.mockResolvedValue(categories);
|
||||||
|
mockGroupFindMany.mockResolvedValue(groups);
|
||||||
|
|
||||||
const locals = createLocals(mockUser({ roleId: 1 }) as any);
|
const locals = createLocals(mockUser({ roleId: 1 }) as any);
|
||||||
const result = await load({ locals } as any);
|
const result = await load({ locals } as any);
|
||||||
@@ -75,7 +83,8 @@ describe('proclamations admin page server', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
records: recordsWithContent,
|
records: recordsWithContent,
|
||||||
relationOptionsData,
|
relationOptionsData,
|
||||||
categories
|
categories,
|
||||||
|
groups
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']> = {}): PageProps['data'] {
|
||||||
|
return {
|
||||||
|
records: [],
|
||||||
|
relationOptionsData,
|
||||||
|
categories,
|
||||||
|
groups,
|
||||||
|
adminUser: superAdmin,
|
||||||
|
userRoleId: superAdmin.roleId,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPage(dataOverrides: Partial<PageProps['data']> = {}) {
|
||||||
|
return render(Page, { params: {}, data: makeData(dataOverrides), form: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRecord = {
|
||||||
|
id: 'proc-1',
|
||||||
|
name: 'Test Proclamation',
|
||||||
|
content: '<p>Secret</p>',
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let addressFormOpen = $state(false);
|
let addressFormOpen = $state(false);
|
||||||
@@ -44,7 +43,6 @@
|
|||||||
} else if ((form as any).userError) {
|
} else if ((form as any).userError) {
|
||||||
userError = (form as any).userError;
|
userError = (form as any).userError;
|
||||||
} else if (form.success) {
|
} else if (form.success) {
|
||||||
toastType = 'success';
|
|
||||||
if ((form as any).address) {
|
if ((form as any).address) {
|
||||||
toastMessage = 'Address saved';
|
toastMessage = 'Address saved';
|
||||||
const addr = (form as any).address as Record<string, unknown>;
|
const addr = (form as any).address as Record<string, unknown>;
|
||||||
@@ -112,8 +110,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
@@ -139,100 +137,74 @@
|
|||||||
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if formError}
|
{#if formError}
|
||||||
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
<div>
|
||||||
{formError}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div>
|
||||||
for="field-userId"
|
<label for="field-userId"> User </label>
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
<select id="field-userId" name="userId" value={String(editingRecord?.userId ?? '')}>
|
||||||
>
|
|
||||||
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>
|
<option value="">-- Select User --</option>
|
||||||
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
|
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{#if fieldErrors.userId}
|
{#if fieldErrors.userId}
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
|
<p>{fieldErrors.userId}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="field-email"> Email (for unregistered users) </label>
|
||||||
for="field-firstName"
|
<input
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
type="text"
|
||||||
>
|
id="field-email"
|
||||||
First Name
|
name="email"
|
||||||
</label>
|
value={String(editingRecord?.email ?? '')}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.email}
|
||||||
|
<p>{fieldErrors.email}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="field-firstName"> First Name </label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="field-firstName"
|
id="field-firstName"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
value={String(editingRecord?.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}
|
{#if fieldErrors.firstName}
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
|
<p>{fieldErrors.firstName}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="field-lastName"> Last Name </label>
|
||||||
for="field-lastName"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Last Name
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="field-lastName"
|
id="field-lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
value={String(editingRecord?.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}
|
{#if fieldErrors.lastName}
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
|
<p>{fieldErrors.lastName}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="field-phoneNumber"> Phone Number </label>
|
||||||
for="field-phoneNumber"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Phone Number
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="field-phoneNumber"
|
id="field-phoneNumber"
|
||||||
name="phoneNumber"
|
name="phoneNumber"
|
||||||
value={String(editingRecord?.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}
|
{#if fieldErrors.phoneNumber}
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
|
<p>{fieldErrors.phoneNumber}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div>
|
||||||
<button
|
<button type="submit">
|
||||||
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'}
|
{editingRecord ? 'Update' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,72 +213,36 @@
|
|||||||
|
|
||||||
{#if editingRecord}
|
{#if editingRecord}
|
||||||
{@const user = editingRecord.user as Record<string, unknown> | undefined}
|
{@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>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User</h3>
|
<h3>User</h3>
|
||||||
<button
|
<button type="button" onclick={toggleUserForm}>
|
||||||
type="button"
|
|
||||||
onclick={toggleUserForm}
|
|
||||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{userFormOpen ? 'Cancel' : 'Edit User'}
|
{userFormOpen ? 'Cancel' : 'Edit User'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
<p>
|
||||||
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
|
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if userFormOpen}
|
{#if userFormOpen}
|
||||||
<form
|
<form method="POST" action="?/updateUser" use:enhance>
|
||||||
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 ?? '')} />
|
<input type="hidden" name="id" value={String(user?.id ?? '')} />
|
||||||
{#if userError}
|
{#if userError}
|
||||||
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
|
<div>
|
||||||
{userError}
|
{userError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div>
|
||||||
for="user-name"
|
<label for="user-name"> Name </label>
|
||||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
<input type="text" id="user-name" name="name" value={userName} />
|
||||||
>
|
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="user-email"> Email </label>
|
||||||
for="user-email"
|
<input type="email" id="user-email" name="email" value={userEmail} />
|
||||||
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>
|
||||||
<div class="flex justify-end">
|
<div>
|
||||||
<button
|
<button type="submit"> Save User </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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -316,172 +252,100 @@
|
|||||||
|
|
||||||
{#if editingRecord}
|
{#if editingRecord}
|
||||||
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
|
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
|
||||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
<div>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Addresses</h3>
|
<h3>Addresses</h3>
|
||||||
<button
|
<button type="button" onclick={toggleAddressForm}>
|
||||||
type="button"
|
|
||||||
onclick={toggleAddressForm}
|
|
||||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{addressFormOpen ? 'Cancel' : 'Add Address +'}
|
{addressFormOpen ? 'Cancel' : 'Add Address +'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if addressFormOpen}
|
{#if addressFormOpen}
|
||||||
<form
|
<form method="POST" action="?/upsertAddress" use:enhance>
|
||||||
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)} />
|
<input type="hidden" name="profileId" value={String(editingRecord.id)} />
|
||||||
{#if addressError}
|
{#if addressError}
|
||||||
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
|
<div>
|
||||||
{addressError}
|
{addressError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div>
|
||||||
for="addr-street"
|
<label for="addr-street"> Street </label>
|
||||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
<input type="text" id="addr-street" name="street" required />
|
||||||
>
|
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="addr-city"> City </label>
|
||||||
for="addr-city"
|
<input type="text" id="addr-city" name="city" required />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="addr-state"> State </label>
|
||||||
for="addr-state"
|
<input type="text" id="addr-state" name="state" required />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="addr-postal"> Postal Code </label>
|
||||||
for="addr-postal"
|
<input type="text" id="addr-postal" name="postalCode" required />
|
||||||
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>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="addr-country"> Country </label>
|
||||||
for="addr-country"
|
<input type="text" id="addr-country" name="country" required />
|
||||||
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>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-4">
|
<div>
|
||||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
<label>
|
||||||
<input type="checkbox" name="isDefaultMailing" class="h-3.5 w-3.5" />
|
<input type="checkbox" name="isDefaultMailing" />
|
||||||
Default Mailing
|
Default Mailing
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
<label>
|
||||||
<input type="checkbox" name="isDefaultBilling" class="h-3.5 w-3.5" />
|
<input type="checkbox" name="isDefaultBilling" />
|
||||||
Default Billing
|
Default Billing
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
<label>
|
||||||
<input type="checkbox" name="isDefaultShipping" class="h-3.5 w-3.5" />
|
<input type="checkbox" name="isDefaultShipping" />
|
||||||
Default Shipping
|
Default Shipping
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex justify-end">
|
<div>
|
||||||
<button
|
<button type="submit"> Save Address </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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if addresses.length > 0}
|
{#if addresses.length > 0}
|
||||||
<table class="w-full text-sm">
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
<tr>
|
||||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th
|
<th>Street</th>
|
||||||
>
|
<th>City</th>
|
||||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">City</th>
|
<th>State</th>
|
||||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">State</th>
|
<th>Zip</th>
|
||||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Zip</th>
|
<th>Country</th>
|
||||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase"
|
<th>M</th>
|
||||||
>Country</th
|
<th>B</th>
|
||||||
>
|
<th>S</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each addresses as addr}
|
{#each addresses as addr}
|
||||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
<tr>
|
||||||
<td class="px-2 py-1">{addr.street as string}</td>
|
<td>{addr.street as string}</td>
|
||||||
<td class="px-2 py-1">{addr.city as string}</td>
|
<td>{addr.city as string}</td>
|
||||||
<td class="px-2 py-1">{addr.state as string}</td>
|
<td>{addr.state as string}</td>
|
||||||
<td class="px-2 py-1">{addr.postalCode as string}</td>
|
<td>{addr.postalCode as string}</td>
|
||||||
<td class="px-2 py-1">{addr.country as string}</td>
|
<td>{addr.country as string}</td>
|
||||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultMailing ? '✓' : ''}</td>
|
<td>{addr.isDefaultMailing ? '✓' : ''}</td>
|
||||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultBilling ? '✓' : ''}</td>
|
<td>{addr.isDefaultBilling ? '✓' : ''}</td>
|
||||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultShipping ? '✓' : ''}</td>
|
<td>{addr.isDefaultShipping ? '✓' : ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-gray-500">No addresses on file.</p>
|
<p>No addresses on file.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
import { enhance } from '$app/forms';
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import FreqForm from './FreqForm.svelte';
|
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
@@ -15,16 +14,18 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let value = $state('');
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form && 'success' in (form as any)) {
|
if (form && 'success' in (form as any)) {
|
||||||
const result = form as any;
|
const result = form as any;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toastMessage = 'Saved successfully';
|
toastMessage = 'Saved successfully';
|
||||||
toastType = 'success';
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
formError = result.error ?? '';
|
formError = result.error ?? '';
|
||||||
@@ -33,6 +34,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const record = editingRecord ?? {};
|
||||||
|
name = String(record['name'] ?? '');
|
||||||
|
value = String(record['value'] ?? '');
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
@@ -53,10 +61,14 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (editingRecord?.id) dirty = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
@@ -77,14 +89,41 @@
|
|||||||
{canDelete}
|
{canDelete}
|
||||||
recordId={editingRecord?.id as string | undefined}
|
recordId={editingRecord?.id as string | undefined}
|
||||||
>
|
>
|
||||||
<FreqForm
|
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
|
||||||
record={editingRecord ?? {}}
|
{#if editingRecord?.id}
|
||||||
{fieldErrors}
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
{formError}
|
{/if}
|
||||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
|
||||||
/>
|
{#if formError}
|
||||||
|
<div>{formError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label for="field-name">Name</label>
|
||||||
|
<input type="text" id="field-name" name="name" bind:value={name} />
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p>{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="field-value">Value</label>
|
||||||
|
<input type="number" id="field-value" name="value" bind:value step="1" />
|
||||||
|
{#if fieldErrors.value}
|
||||||
|
<p>{fieldErrors.value}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
|
||||||
|
{editingRecord ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
|
|
||||||
let {
|
|
||||||
record = {} as Record<string, unknown>,
|
|
||||||
fieldErrors = {} as Record<string, string>,
|
|
||||||
formError = '',
|
|
||||||
action = '?/upsert',
|
|
||||||
submitLabel = 'Save'
|
|
||||||
}: {
|
|
||||||
record?: Record<string, unknown>;
|
|
||||||
fieldErrors?: Record<string, string>;
|
|
||||||
formError?: string;
|
|
||||||
action?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let name = $state(String(record.name ?? ''));
|
|
||||||
let value = $state(String(record.value ?? ''));
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form {action} method="POST" use:enhance oninput={markDirty}>
|
|
||||||
{#if record?.id}
|
|
||||||
<input type="hidden" name="id" value={String(record.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-name"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="field-name"
|
|
||||||
name="name"
|
|
||||||
bind:value={name}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.name}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-value"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Value
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="field-value"
|
|
||||||
name="value"
|
|
||||||
bind:value
|
|
||||||
step="1"
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.value
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.value}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.value}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!!record?.id && !dirty}
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
|
||||||
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
|
|
||||||
|
|
||||||
const config = getModelConfig('subscription-levels')!;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
|
||||||
return loadRecords(locals, config);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
upsert: async ({ request, locals }) => {
|
|
||||||
return upsertRecord(request, config, locals);
|
|
||||||
},
|
|
||||||
delete: async ({ request, locals }) => {
|
|
||||||
return deleteRecord(request, config, locals);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
|
|
||||||
let {
|
|
||||||
record = {} as Record<string, unknown>,
|
|
||||||
fieldErrors = {} as Record<string, string>,
|
|
||||||
formError = '',
|
|
||||||
action = '?/upsert',
|
|
||||||
submitLabel = 'Save'
|
|
||||||
}: {
|
|
||||||
record?: Record<string, unknown>;
|
|
||||||
fieldErrors?: Record<string, string>;
|
|
||||||
formError?: string;
|
|
||||||
action?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let name = $state(String(record.name ?? ''));
|
|
||||||
let description = $state(String(record.description ?? ''));
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form {action} method="POST" use:enhance oninput={markDirty}>
|
|
||||||
{#if record?.id}
|
|
||||||
<input type="hidden" name="id" value={String(record.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-name"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="field-name"
|
|
||||||
name="name"
|
|
||||||
bind:value={name}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.name}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="field-description"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="field-description"
|
|
||||||
name="description"
|
|
||||||
bind:value={description}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.description
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.description}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.description}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!!record?.id && !dirty}
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
import { enhance } from '$app/forms';
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import UserRoleForm from './UserRoleForm.svelte';
|
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
@@ -15,16 +14,17 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form && 'success' in (form as any)) {
|
if (form && 'success' in (form as any)) {
|
||||||
const result = form as any;
|
const result = form as any;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toastMessage = 'Saved successfully';
|
toastMessage = 'Saved successfully';
|
||||||
toastType = 'success';
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
formError = result.error ?? '';
|
formError = result.error ?? '';
|
||||||
@@ -33,6 +33,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = String((editingRecord ?? {})['name'] ?? '');
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
formError = '';
|
formError = '';
|
||||||
@@ -53,10 +58,14 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (editingRecord?.id) dirty = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
@@ -77,14 +86,33 @@
|
|||||||
{canDelete}
|
{canDelete}
|
||||||
recordId={editingRecord?.id as string | undefined}
|
recordId={editingRecord?.id as string | undefined}
|
||||||
>
|
>
|
||||||
<UserRoleForm
|
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
|
||||||
record={editingRecord ?? {}}
|
{#if editingRecord?.id}
|
||||||
{fieldErrors}
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
{formError}
|
{/if}
|
||||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
|
||||||
/>
|
{#if formError}
|
||||||
|
<div>{formError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label for="field-name">Name</label>
|
||||||
|
<input type="text" id="field-name" name="name" bind:value={name} />
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p>{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
|
||||||
|
{editingRecord ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
|
|
||||||
let {
|
|
||||||
record = {} as Record<string, unknown>,
|
|
||||||
fieldErrors = {} as Record<string, string>,
|
|
||||||
formError = '',
|
|
||||||
action = '?/upsert',
|
|
||||||
submitLabel = 'Save'
|
|
||||||
}: {
|
|
||||||
record?: Record<string, unknown>;
|
|
||||||
fieldErrors?: Record<string, string>;
|
|
||||||
formError?: string;
|
|
||||||
action?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let name = $state(String(record.name ?? ''));
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
if (record.id) dirty = true;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form {action} method="POST" use:enhance oninput={markDirty}>
|
|
||||||
{#if record?.id}
|
|
||||||
<input type="hidden" name="id" value={String(record.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-name"
|
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="field-name"
|
|
||||||
name="name"
|
|
||||||
bind:value={name}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.name}
|
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!!record?.id && !dirty}
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
let toastMessage = $state('');
|
let toastMessage = $state('');
|
||||||
let toastType = $state<'success' | 'error'>('success');
|
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
|
||||||
@@ -24,7 +22,6 @@
|
|||||||
const result = form as any;
|
const result = form as any;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toastMessage = 'Saved successfully';
|
toastMessage = 'Saved successfully';
|
||||||
toastType = 'success';
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} else {
|
} else {
|
||||||
formError = result.error ?? '';
|
formError = result.error ?? '';
|
||||||
@@ -55,8 +52,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6">
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
<h1>{config.displayName}</h1>
|
||||||
<AdminTable
|
<AdminTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
listFields={config.listFields}
|
listFields={config.listFields}
|
||||||
@@ -87,5 +84,5 @@
|
|||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|
||||||
{#if toastMessage}
|
{#if toastMessage}
|
||||||
<Toast message={toastMessage} type={toastType} />
|
<Toast message={toastMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -35,43 +35,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if formError}
|
{#if formError}
|
||||||
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
<div>
|
||||||
{formError}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div>
|
||||||
for="field-roleId"
|
<label for="field-roleId"> Role </label>
|
||||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
<select id="field-roleId" name="roleId" bind:value={roleId}>
|
||||||
>
|
|
||||||
Role
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="field-roleId"
|
|
||||||
name="roleId"
|
|
||||||
bind:value={roleId}
|
|
||||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.roleId
|
|
||||||
? 'border-red-400'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="">-- Select Role --</option>
|
<option value="">-- Select Role --</option>
|
||||||
{#each relationOptionsData.roleId ?? [] as opt (String(opt.value))}
|
{#each relationOptionsData.roleId ?? [] as opt (String(opt.value))}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{#if fieldErrors.roleId}
|
{#if fieldErrors.roleId}
|
||||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.roleId}</p>
|
<p>{fieldErrors.roleId}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div>
|
||||||
<button
|
<button type="submit" disabled={!!record?.id && !dirty}>
|
||||||
type="submit"
|
|
||||||
disabled={!!record?.id && !dirty}
|
|
||||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,21 +12,11 @@ function generateLicenseKey(): string {
|
|||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { email, name, subscription_level } = body;
|
const { email, name } = body;
|
||||||
|
|
||||||
if (!email || typeof email !== 'string') {
|
if (!email || typeof email !== 'string') {
|
||||||
error(400, 'email is required');
|
error(400, 'email is required');
|
||||||
}
|
}
|
||||||
if (!subscription_level || typeof subscription_level !== 'string') {
|
|
||||||
error(400, 'subscription_level is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = await prisma.subscriptionLevel.findFirst({
|
|
||||||
where: { name: subscription_level }
|
|
||||||
});
|
|
||||||
if (!level) {
|
|
||||||
error(400, `subscription_level "${subscription_level}" not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = await prisma.user.findUnique({ where: { email } });
|
let user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
@@ -83,7 +73,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
data: {
|
data: {
|
||||||
key,
|
key,
|
||||||
active: true,
|
active: true,
|
||||||
level: subscription_level,
|
|
||||||
userId: user.id
|
userId: user.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
email_address: email,
|
email_address: email
|
||||||
subscription_level: storedKey.level
|
|
||||||
}),
|
}),
|
||||||
{ headers: { 'content-type': 'application/json' } }
|
{ headers: { 'content-type': 'application/json' } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
throw error(400, 'invalid JSON body');
|
throw error(400, 'invalid JSON body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, name, subscription_level } = body as Record<string, unknown>;
|
const { email, name } = body as Record<string, unknown>;
|
||||||
|
|
||||||
if (typeof email !== 'string' || !email.trim()) {
|
if (typeof email !== 'string' || !email.trim()) {
|
||||||
error(400, 'email is required');
|
error(400, 'email is required');
|
||||||
@@ -71,10 +71,6 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
error(400, 'email is required');
|
error(400, 'email is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof subscription_level !== 'string' || !subscription_level.trim()) {
|
|
||||||
error(400, 'subscription_level is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailLimit = rateLimit({
|
const emailLimit = rateLimit({
|
||||||
key: `onboarding:email:${cleanEmail}`,
|
key: `onboarding:email:${cleanEmail}`,
|
||||||
limit: 3,
|
limit: 3,
|
||||||
@@ -84,12 +80,6 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
return rateLimited(emailLimit.retryAfterMs);
|
return rateLimited(emailLimit.retryAfterMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelName = subscription_level.trim();
|
|
||||||
const level = await prisma.subscriptionLevel.findFirst({ where: { name: levelName } });
|
|
||||||
if (!level) {
|
|
||||||
error(400, `subscription_level "${levelName}" not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
typeof name === 'string' && name.trim() ? name.trim().slice(0, 120) : cleanEmail.split('@')[0];
|
typeof name === 'string' && name.trim() ? name.trim().slice(0, 120) : cleanEmail.split('@')[0];
|
||||||
|
|
||||||
@@ -148,7 +138,6 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
data: {
|
data: {
|
||||||
key,
|
key,
|
||||||
active: true,
|
active: true,
|
||||||
level: levelName,
|
|
||||||
userId: user.id
|
userId: user.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ vi.mock('$lib/server/resend', () => ({
|
|||||||
|
|
||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
subscriptionLevel: { findFirst: vi.fn() },
|
|
||||||
user: { findUnique: vi.fn() },
|
user: { findUnique: vi.fn() },
|
||||||
verification: { findFirst: vi.fn() },
|
verification: { findFirst: vi.fn() },
|
||||||
licenseKey: { create: vi.fn() }
|
licenseKey: { create: vi.fn() }
|
||||||
@@ -40,7 +39,6 @@ type POSTEvent = Parameters<typeof POST>[0];
|
|||||||
const SECRET = 'test-onboarding-secret';
|
const SECRET = 'test-onboarding-secret';
|
||||||
const PATH = '/api/v2/automations/create/account';
|
const PATH = '/api/v2/automations/create/account';
|
||||||
|
|
||||||
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
|
|
||||||
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>;
|
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>;
|
||||||
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
|
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
|
||||||
@@ -88,13 +86,12 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates a license key for an existing user without a new signup', async () => {
|
it('creates a license key for an existing user without a new signup', async () => {
|
||||||
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
|
|
||||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||||
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
|
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
|
||||||
sendMailMock.mockResolvedValue({});
|
sendMailMock.mockResolvedValue({});
|
||||||
|
|
||||||
const res = await POST({
|
const res = await POST({
|
||||||
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
|
request: signedOnboardingRequest({ email: 'user@example.com' }),
|
||||||
url: new URL(`http://localhost${PATH}`)
|
url: new URL(`http://localhost${PATH}`)
|
||||||
} as POSTEvent);
|
} as POSTEvent);
|
||||||
|
|
||||||
@@ -105,12 +102,15 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
user_id: 'user-1'
|
user_id: 'user-1'
|
||||||
});
|
});
|
||||||
expect(authApi.signUpEmail).not.toHaveBeenCalled();
|
expect(authApi.signUpEmail).not.toHaveBeenCalled();
|
||||||
expect(mockLicenseCreate).toHaveBeenCalledTimes(1);
|
expect(mockLicenseCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ active: true })
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signs up a new user and sends registration + license emails', async () => {
|
it('signs up a new user and sends registration + license emails', async () => {
|
||||||
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
|
|
||||||
mockUserFindUnique
|
mockUserFindUnique
|
||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
|
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
|
||||||
@@ -120,7 +120,7 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
authApi.signUpEmail.mockResolvedValue({});
|
authApi.signUpEmail.mockResolvedValue({});
|
||||||
|
|
||||||
const res = await POST({
|
const res = await POST({
|
||||||
request: signedOnboardingRequest({ email: 'new@example.com', subscription_level: 'Elite' }),
|
request: signedOnboardingRequest({ email: 'new@example.com' }),
|
||||||
url: new URL(`http://localhost${PATH}`)
|
url: new URL(`http://localhost${PATH}`)
|
||||||
} as POSTEvent);
|
} as POSTEvent);
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
const req = new Request(`http://localhost${PATH}`, {
|
const req = new Request(`http://localhost${PATH}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ email: 'user@example.com', subscription_level: 'Basic' })
|
body: JSON.stringify({ email: 'user@example.com' })
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent);
|
const res = await POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent);
|
||||||
@@ -152,10 +152,7 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
|
|
||||||
it('rejects a request signed with the wrong secret', async () => {
|
it('rejects a request signed with the wrong secret', async () => {
|
||||||
const res = await POST({
|
const res = await POST({
|
||||||
request: signedOnboardingRequest(
|
request: signedOnboardingRequest({ email: 'user@example.com' }, 'wrong-secret'),
|
||||||
{ email: 'user@example.com', subscription_level: 'Basic' },
|
|
||||||
'wrong-secret'
|
|
||||||
),
|
|
||||||
url: new URL(`http://localhost${PATH}`)
|
url: new URL(`http://localhost${PATH}`)
|
||||||
} as POSTEvent);
|
} as POSTEvent);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@@ -165,7 +162,7 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
it('rejects a missing email', async () => {
|
it('rejects a missing email', async () => {
|
||||||
await expectHttpError(
|
await expectHttpError(
|
||||||
POST({
|
POST({
|
||||||
request: signedOnboardingRequest({ subscription_level: 'Basic' }),
|
request: signedOnboardingRequest({}),
|
||||||
url: new URL(`http://localhost${PATH}`)
|
url: new URL(`http://localhost${PATH}`)
|
||||||
} as POSTEvent),
|
} as POSTEvent),
|
||||||
400,
|
400,
|
||||||
@@ -176,7 +173,6 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
it('rejects an oversized body with 413', async () => {
|
it('rejects an oversized body with 413', async () => {
|
||||||
const rawBody = JSON.stringify({
|
const rawBody = JSON.stringify({
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
subscription_level: 'Basic',
|
|
||||||
name: 'x'.repeat(64 * 1024 + 1)
|
name: 'x'.repeat(64 * 1024 + 1)
|
||||||
});
|
});
|
||||||
expect(rawBody.length).toBeGreaterThan(64 * 1024);
|
expect(rawBody.length).toBeGreaterThan(64 * 1024);
|
||||||
@@ -194,40 +190,21 @@ describe('POST /api/v2/automations/create/account', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown subscription level', async () => {
|
|
||||||
mockLevelFindFirst.mockResolvedValue(null);
|
|
||||||
await expectHttpError(
|
|
||||||
POST({
|
|
||||||
request: signedOnboardingRequest({
|
|
||||||
email: 'user@example.com',
|
|
||||||
subscription_level: 'Titanium'
|
|
||||||
}),
|
|
||||||
url: new URL(`http://localhost${PATH}`)
|
|
||||||
} as POSTEvent),
|
|
||||||
400,
|
|
||||||
'subscription_level "Titanium" not found'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rate limits repeated requests for the same email', async () => {
|
it('rate limits repeated requests for the same email', async () => {
|
||||||
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
|
|
||||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||||
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
|
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
|
||||||
sendMailMock.mockResolvedValue({});
|
sendMailMock.mockResolvedValue({});
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const res = await POST({
|
const res = await POST({
|
||||||
request: signedOnboardingRequest({
|
request: signedOnboardingRequest({ email: 'user@example.com' }),
|
||||||
email: 'user@example.com',
|
|
||||||
subscription_level: 'Basic'
|
|
||||||
}),
|
|
||||||
url: new URL(`http://localhost${PATH}`)
|
url: new URL(`http://localhost${PATH}`)
|
||||||
} as POSTEvent);
|
} as POSTEvent);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await POST({
|
const res = await POST({
|
||||||
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
|
request: signedOnboardingRequest({ email: 'user@example.com' }),
|
||||||
url: new URL(`http://localhost${PATH}`)
|
url: new URL(`http://localhost${PATH}`)
|
||||||
} as POSTEvent);
|
} as POSTEvent);
|
||||||
expect(res.status).toBe(429);
|
expect(res.status).toBe(429);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { jsonOk } from '$lib/server/v2-helpers';
|
||||||
|
import { serializeGroup } from '$lib/server/v2-serializers';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const groups = await prisma.proclamationGroup.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' }
|
||||||
|
});
|
||||||
|
return jsonOk(groups.map(serializeGroup));
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
proclamationGroup: { findMany: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { GET } from './+server';
|
||||||
|
|
||||||
|
type GETEvent = Parameters<typeof GET>[0];
|
||||||
|
|
||||||
|
const mockFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('GET /api/v2/groups', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups ordered by sortOrder', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: 1, name: 'General', sortOrder: 0 },
|
||||||
|
{ id: 2, name: 'Specific', sortOrder: 1 },
|
||||||
|
{ id: 3, name: 'Precise', sortOrder: 2 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await GET({} as GETEvent);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual([
|
||||||
|
{ id: 1, name: 'General', sortOrder: 0 },
|
||||||
|
{ id: 2, name: 'Specific', sortOrder: 1 },
|
||||||
|
{ id: 3, name: 'Precise', sortOrder: 2 }
|
||||||
|
]);
|
||||||
|
expect(mockFindMany).toHaveBeenCalledWith({ orderBy: { sortOrder: 'asc' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when no groups exist', async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await GET({} as GETEvent);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,16 +50,10 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
throw error(400, 'key does not belong to specified user');
|
throw error(400, 'key does not belong to specified user');
|
||||||
}
|
}
|
||||||
|
|
||||||
const level = await prisma.subscriptionLevel.findFirst({
|
|
||||||
where: { name: storedKey.level }
|
|
||||||
});
|
|
||||||
|
|
||||||
return jsonOk(
|
return jsonOk(
|
||||||
serializeLicense({
|
serializeLicense({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
emailAddress: email,
|
emailAddress: email
|
||||||
levelName: storedKey.level,
|
|
||||||
level
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
|||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
licenseKey: { findFirst: vi.fn() },
|
licenseKey: { findFirst: vi.fn() },
|
||||||
user: { findUnique: vi.fn() },
|
user: { findUnique: vi.fn() }
|
||||||
subscriptionLevel: { findFirst: vi.fn() }
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ type POSTEvent = Parameters<typeof POST>[0];
|
|||||||
|
|
||||||
const mockKeyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
const mockKeyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||||
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
function licenseRequest(body: unknown): Request {
|
function licenseRequest(body: unknown): Request {
|
||||||
return new Request('http://localhost/api/v2/license', {
|
return new Request('http://localhost/api/v2/license', {
|
||||||
@@ -44,7 +42,6 @@ const storedKey = {
|
|||||||
id: 'key-1',
|
id: 'key-1',
|
||||||
key: 'ABCD-EFGH-IJKL-MNOP',
|
key: 'ABCD-EFGH-IJKL-MNOP',
|
||||||
active: true,
|
active: true,
|
||||||
level: 'Advanced',
|
|
||||||
userId: 'user-1'
|
userId: 'user-1'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,42 +51,21 @@ describe('POST /api/v2/license', () => {
|
|||||||
_clearRateLimits();
|
_clearRateLimits();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the subscription_level object for a valid key', async () => {
|
it('returns key and email_address for a valid key', async () => {
|
||||||
mockKeyFindFirst.mockResolvedValue(storedKey);
|
mockKeyFindFirst.mockResolvedValue(storedKey);
|
||||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||||
mockLevelFindFirst.mockResolvedValue({
|
|
||||||
id: 2,
|
|
||||||
name: 'Advanced',
|
|
||||||
description: 'General + Specific'
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await POST({
|
const res = await POST({
|
||||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
||||||
} as POSTEvent);
|
} as POSTEvent);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual({
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(body).toEqual({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
email_address: 'user@example.com',
|
email_address: 'user@example.com'
|
||||||
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a structurally consistent subscription_level for an unknown level name', async () => {
|
|
||||||
mockKeyFindFirst.mockResolvedValue({ ...storedKey, level: 'SomeCustomLevel' });
|
|
||||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
|
||||||
mockLevelFindFirst.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const res = await POST({
|
|
||||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
|
||||||
} as POSTEvent);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(await res.json()).toEqual({
|
|
||||||
key: storedKey.key,
|
|
||||||
email_address: 'user@example.com',
|
|
||||||
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
|
|
||||||
});
|
});
|
||||||
|
expect(Object.keys(body)).not.toContain('subscription_level');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a missing license_key', async () => {
|
it('rejects a missing license_key', async () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ProclamationWithFrequency
|
type ProclamationWithFrequency
|
||||||
} from '$lib/server/v2-serializers';
|
} from '$lib/server/v2-serializers';
|
||||||
|
|
||||||
|
// groupId is a scalar column; no relation include needed for serialization.
|
||||||
const PROCLAMATION_INCLUDE = { solfeggioFrequency: true } as const;
|
const PROCLAMATION_INCLUDE = { solfeggioFrequency: true } as const;
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ request }) => {
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ const peaceProclamation = {
|
|||||||
name: 'Peace & Relaxation',
|
name: 'Peace & Relaxation',
|
||||||
content: '<p>The text content</p>',
|
content: '<p>The text content</p>',
|
||||||
categoryId: 5,
|
categoryId: 5,
|
||||||
subscriptionLevelId: 1,
|
groupId: 1,
|
||||||
|
|
||||||
solfeggioFrequencyId: 5,
|
solfeggioFrequencyId: 5,
|
||||||
solfeggioFrequency: { id: 5, name: 'Solar Plexus', value: 528 }
|
solfeggioFrequency: { id: 5, name: 'Solar Plexus', value: 528 }
|
||||||
};
|
};
|
||||||
@@ -48,7 +49,7 @@ const crown936 = {
|
|||||||
name: 'Crown 936',
|
name: 'Crown 936',
|
||||||
content: '<p>no canonical id</p>',
|
content: '<p>no canonical id</p>',
|
||||||
categoryId: 4,
|
categoryId: 4,
|
||||||
subscriptionLevelId: 1,
|
|
||||||
solfeggioFrequencyId: 1,
|
solfeggioFrequencyId: 1,
|
||||||
solfeggioFrequency: { id: 1, name: 'Crown', value: 936 }
|
solfeggioFrequency: { id: 1, name: 'Crown', value: 936 }
|
||||||
};
|
};
|
||||||
@@ -88,8 +89,8 @@ describe('GET /api/v2/proclamations', () => {
|
|||||||
name: peaceProclamation.name,
|
name: peaceProclamation.name,
|
||||||
content: peaceProclamation.content,
|
content: peaceProclamation.content,
|
||||||
categoryId: 5,
|
categoryId: 5,
|
||||||
|
groupId: 1,
|
||||||
solfeggioFrequencyId: 5,
|
solfeggioFrequencyId: 5,
|
||||||
subscriptionLevelId: 1,
|
|
||||||
colorPalette: palettes[0].colors
|
colorPalette: palettes[0].colors
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { prisma } from '$lib/db';
|
|
||||||
import { jsonOk } from '$lib/server/v2-helpers';
|
|
||||||
import { serializeSubscriptionLevel } from '$lib/server/v2-serializers';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
|
||||||
const levels = await prisma.subscriptionLevel.findMany({
|
|
||||||
orderBy: { id: 'asc' }
|
|
||||||
});
|
|
||||||
return jsonOk(levels.map(serializeSubscriptionLevel));
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user