Merge dev
This commit is contained in:
Vendored
+4
-4
@@ -1,12 +1,12 @@
|
||||
import type { Session, User } from "better-auth";
|
||||
import type { Session, User } from 'better-auth';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
session: Session,
|
||||
user: User,
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
export {};
|
||||
|
||||
+22
-11
@@ -1,14 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function () {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (
|
||||
theme === 'dark' ||
|
||||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body class="m-0" data-sveltekit-preload-data="hover">
|
||||
<div class="h-dvh w-dvw flex flex-col">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body class="m-0" data-sveltekit-preload-data="hover">
|
||||
<div class="flex h-dvh w-dvw flex-col">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { getSession } = vi.hoisted(() => ({
|
||||
getSession: vi.fn()
|
||||
}));
|
||||
|
||||
const { svelteKitHandler } = vi.hoisted(() => ({
|
||||
svelteKitHandler: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$app/environment', () => ({
|
||||
building: false
|
||||
}));
|
||||
|
||||
vi.mock('better-auth/svelte-kit', () => ({
|
||||
svelteKitHandler
|
||||
}));
|
||||
|
||||
vi.mock('$lib/auth', () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/api-key', () => ({
|
||||
validateApiKey: vi.fn()
|
||||
}));
|
||||
|
||||
import { handle } from './hooks.server';
|
||||
|
||||
function createMockEvent(path = '/dashboard') {
|
||||
return {
|
||||
request: new Request(`http://localhost${path}`),
|
||||
url: new URL(`http://localhost${path}`),
|
||||
locals: {} as Record<string, unknown>,
|
||||
resolve: vi.fn()
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('hooks.server handle', () => {
|
||||
beforeEach(() => {
|
||||
getSession.mockReset();
|
||||
svelteKitHandler.mockReset();
|
||||
});
|
||||
|
||||
it('sets session and user on locals when authenticated', async () => {
|
||||
const sessionData = {
|
||||
session: { id: 'sess-1', userId: 'user-1', expiresAt: new Date() },
|
||||
user: { id: 'user-1', name: 'Jane', email: 'jane@example.com' }
|
||||
};
|
||||
getSession.mockResolvedValue(sessionData);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
|
||||
expect(event.locals.session).toEqual(sessionData.session);
|
||||
expect(event.locals.user).toEqual(sessionData.user);
|
||||
expect(svelteKitHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event,
|
||||
resolve: event.resolve,
|
||||
building: false
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set locals when not authenticated', async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
|
||||
expect(event.locals.session).toBeUndefined();
|
||||
expect(event.locals.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set locals when getSession returns undefined', async () => {
|
||||
getSession.mockResolvedValue(undefined);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
|
||||
expect(event.locals.session).toBeUndefined();
|
||||
expect(event.locals.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes building flag from $app/environment to svelteKitHandler', async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
|
||||
expect(svelteKitHandler).toHaveBeenCalledWith(expect.objectContaining({ building: false }));
|
||||
});
|
||||
|
||||
it('calls svelteKitHandler with auth instance', async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
|
||||
expect(svelteKitHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
api: expect.objectContaining({ getSession })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+37
-14
@@ -1,18 +1,41 @@
|
||||
import { auth } from "$lib/auth";
|
||||
import { svelteKitHandler } from "better-auth/svelte-kit";
|
||||
import { building } from "$app/environment";
|
||||
import { auth } from '$lib/auth';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { building } from '$app/environment';
|
||||
import { validateApiKey } from '$lib/server/api-key';
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
// Fetch current session from Better Auth
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers,
|
||||
});
|
||||
if (
|
||||
event.url.pathname.startsWith('/api/v1/') &&
|
||||
!event.url.pathname.startsWith('/api/v1/automations/')
|
||||
) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Make session and user available on server
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
}
|
||||
const key = authHeader.slice(7);
|
||||
const record = await validateApiKey(key);
|
||||
if (!record) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
}
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { createAuthClient } from "better-auth/svelte"
|
||||
import { createAuthClient } from 'better-auth/svelte';
|
||||
|
||||
export const authClient = createAuthClient({});
|
||||
|
||||
const signIn = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
};
|
||||
+54
-24
@@ -1,26 +1,56 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { prisma } from "$lib/db";
|
||||
import { sveltekitCookies } from "better-auth/svelte-kit";
|
||||
import { getRequestEvent } from "$app/server";
|
||||
import { env } from "$env/dynamic/private";
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
import { prisma } from '$lib/db';
|
||||
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { sendMail } from './server/resend';
|
||||
import { sendVerificationEmail } from './server/email-verification';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
prompt: "select_account",
|
||||
clientId: env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
}
|
||||
},
|
||||
plugins: [sveltekitCookies(getRequestEvent)],
|
||||
})
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'postgresql'
|
||||
}),
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ user, token }) => {
|
||||
void sendVerificationEmail({
|
||||
email: user.email,
|
||||
token
|
||||
}).catch((e) => {
|
||||
console.error('sendVerificationEmail template email failed:', e);
|
||||
});
|
||||
}
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
void sendMail({
|
||||
to: user.email,
|
||||
templateId: 'password-reset-email',
|
||||
data: {
|
||||
target_email_address: user.email,
|
||||
reset_link: url
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.error('sendResetPassword template email failed:', e);
|
||||
});
|
||||
}
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
prompt: 'select_account',
|
||||
clientId: env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET as string
|
||||
}
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 100
|
||||
},
|
||||
trustedOrigins: ['http://localhost:5173', 'https://portal.rhemawavetech.com'],
|
||||
plugins: [sveltekitCookies(getRequestEvent)]
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
open = false,
|
||||
title = '',
|
||||
onClose,
|
||||
children,
|
||||
canDelete = false,
|
||||
recordId,
|
||||
deleteAction = '?/delete'
|
||||
}: {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
children: import('svelte').Snippet;
|
||||
canDelete?: boolean;
|
||||
recordId?: string;
|
||||
deleteAction?: string;
|
||||
} = $props();
|
||||
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose?.();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!recordId) return;
|
||||
if (confirm('Are you sure you want to delete this record? This cannot be undone.')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = deleteAction;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'id';
|
||||
input.value = recordId;
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
role="presentation"
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-12"
|
||||
onclick={handleBackdrop}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<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}
|
||||
<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}
|
||||
<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 class="px-6 py-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import type { PrismaScalarType } from '$lib/svelte-admin/types';
|
||||
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
|
||||
|
||||
let {
|
||||
records = [],
|
||||
listFields = [],
|
||||
searchFields = [],
|
||||
fieldTypes = {} as Record<string, PrismaScalarType>,
|
||||
singular = 'record',
|
||||
draggable = false,
|
||||
copyableFields = [] as string[],
|
||||
onRowClick,
|
||||
onNew,
|
||||
onReorder
|
||||
}: {
|
||||
records: Record<string, unknown>[];
|
||||
listFields: string[];
|
||||
searchFields?: string[];
|
||||
fieldTypes?: Record<string, PrismaScalarType>;
|
||||
singular?: string;
|
||||
draggable?: boolean;
|
||||
copyableFields?: string[];
|
||||
onRowClick?: (record: Record<string, unknown>) => void;
|
||||
onNew?: () => void;
|
||||
onReorder?: (ids: (string | number)[]) => void;
|
||||
} = $props();
|
||||
|
||||
let search = $state('');
|
||||
let sortCol = $state<string | null>(null);
|
||||
let sortAsc = $state(true);
|
||||
|
||||
let filtered = $derived(
|
||||
search
|
||||
? records.filter((r) =>
|
||||
searchFields.some((f) => {
|
||||
const v = resolveFieldValue(r, f);
|
||||
return v != null && String(v).toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
)
|
||||
: records
|
||||
);
|
||||
|
||||
let sorted = $derived(
|
||||
sortCol
|
||||
? [...filtered].sort((a, b) => {
|
||||
const va = resolveFieldValue(a, sortCol!);
|
||||
const vb = resolveFieldValue(b, sortCol!);
|
||||
if (va == null) return 1;
|
||||
if (vb == null) return -1;
|
||||
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
|
||||
return sortAsc ? cmp : -cmp;
|
||||
})
|
||||
: filtered
|
||||
);
|
||||
|
||||
let isDragging = $state(false);
|
||||
|
||||
let dragOverride = $state<Record<string, unknown>[] | null>(null);
|
||||
|
||||
let rows = $derived(dragOverride ?? sorted);
|
||||
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let hoverIndex = $state<number | null>(null);
|
||||
|
||||
function toggleSort(field: string) {
|
||||
if (sortCol === field) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortCol = field;
|
||||
sortAsc = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, i: number) {
|
||||
dragOverride = sorted;
|
||||
isDragging = true;
|
||||
dragIndex = i;
|
||||
hoverIndex = i;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', '');
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, i: number) {
|
||||
if (dragIndex === null) return;
|
||||
e.preventDefault();
|
||||
if (i === hoverIndex) return;
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
|
||||
const reordered = rows.slice();
|
||||
const [moved] = reordered.splice(dragIndex, 1);
|
||||
reordered.splice(i, 0, moved);
|
||||
dragOverride = reordered;
|
||||
dragIndex = i;
|
||||
hoverIndex = i;
|
||||
}
|
||||
|
||||
async function handleDrop() {
|
||||
if (dragIndex === null) return;
|
||||
const ids = rows.map((r) => r.id as string | number);
|
||||
await onReorder?.(ids);
|
||||
isDragging = false;
|
||||
dragIndex = null;
|
||||
hoverIndex = null;
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragOverride = null;
|
||||
isDragging = false;
|
||||
dragIndex = null;
|
||||
hoverIndex = null;
|
||||
}
|
||||
|
||||
function copyValue(v: unknown) {
|
||||
navigator.clipboard.writeText(String(v ?? ''));
|
||||
}
|
||||
|
||||
function formatCellValue(v: unknown, field: string): string {
|
||||
if (v == null) return '';
|
||||
const ft = fieldTypes[field];
|
||||
if (ft === 'DateTime') {
|
||||
if (v instanceof Date) return v.toLocaleString();
|
||||
return new Date(v as string).toLocaleString();
|
||||
}
|
||||
if (ft === 'Boolean') return v ? 'Active' : 'Inactive';
|
||||
if (ft === 'Float') {
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? String(v) : n.toFixed(2);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
{#if searchFields.length > 0}
|
||||
<input
|
||||
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}
|
||||
<div></div>
|
||||
{/if}
|
||||
{#if onNew}
|
||||
<button
|
||||
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} +
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if draggable}
|
||||
<th scope="col" class="w-10 px-2 py-2"></th>
|
||||
{/if}
|
||||
{#each listFields as field (field)}
|
||||
<th
|
||||
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)}
|
||||
{#if sortCol === field}
|
||||
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
|
||||
<tr
|
||||
{draggable}
|
||||
ondragstart={(e) => handleDragStart(e, i)}
|
||||
ondragover={(e) => handleDragOver(e, i)}
|
||||
ondrop={handleDrop}
|
||||
ondragend={handleDragEnd}
|
||||
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}
|
||||
<td
|
||||
class="w-10 cursor-grab px-2 py-2 text-center"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span class="text-gray-400 select-none">⠿</span>
|
||||
</td>
|
||||
{/if}
|
||||
{#each listFields as field (field)}
|
||||
<td class="px-4 py-2 text-sm">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{formatCellValue(resolveFieldValue(record, field), field)}
|
||||
{#if copyableFields.includes(field)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyValue(resolveFieldValue(record, field));
|
||||
}}
|
||||
title="Copy to clipboard"
|
||||
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
|
||||
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td
|
||||
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.
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Option = { label: string; value: string | number };
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
name: string;
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
value?: string | number;
|
||||
};
|
||||
|
||||
let { id, name, options, placeholder = 'Search...', value }: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
let open = $state(false);
|
||||
let highlightIndex = $state(-1);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
let listEl = $state<HTMLUListElement | null>(null);
|
||||
|
||||
let selectedValue = $state<string | number | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (value != null && value !== '') {
|
||||
selectedValue = value;
|
||||
}
|
||||
});
|
||||
|
||||
let selectedLabel = $derived(() => {
|
||||
if (selectedValue == null || selectedValue === '') return '';
|
||||
const match = options.find((o) => String(o.value) === String(selectedValue));
|
||||
return match?.label ?? '';
|
||||
});
|
||||
|
||||
let filtered = $derived(() => {
|
||||
if (!search) return options;
|
||||
const q = search.toLowerCase();
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function select(opt: Option) {
|
||||
selectedValue = opt.value;
|
||||
search = '';
|
||||
open = false;
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedValue = undefined;
|
||||
search = '';
|
||||
open = true;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
open = true;
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
function handleInputBlur(e: FocusEvent) {
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related?.closest(`[data-combobox="${id}"]`)) return;
|
||||
open = false;
|
||||
highlightIndex = -1;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const items = filtered();
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
highlightIndex = Math.min(highlightIndex + 1, items.length - 1);
|
||||
scrollToHighlighted();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIndex = Math.max(highlightIndex - 1, 0);
|
||||
scrollToHighlighted();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (open && highlightIndex >= 0 && highlightIndex < items.length) {
|
||||
select(items[highlightIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
open = false;
|
||||
search = '';
|
||||
highlightIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHighlighted() {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listEl?.children[highlightIndex] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" data-combobox={id}>
|
||||
<input type="hidden" {name} value={selectedValue ?? ''} />
|
||||
<input type="hidden" name={name + 'Label'} value={selectedValue != null && selectedValue !== '' ? selectedLabel() : search} />
|
||||
|
||||
{#if selectedValue != null && selectedValue !== ''}
|
||||
<div
|
||||
class="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer text-left text-gray-900"
|
||||
onclick={() => clear()}
|
||||
>
|
||||
{selectedLabel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 shrink-0 text-gray-400 hover:text-gray-600"
|
||||
onclick={() => clear()}
|
||||
tabindex={-1}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
{id}
|
||||
bind:this={inputEl}
|
||||
bind:value={search}
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
|
||||
onfocus={handleInputFocus}
|
||||
onblur={handleInputBlur}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={listEl}
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
|
||||
role="listbox"
|
||||
>
|
||||
{#each filtered() as opt, i}
|
||||
<li role="option" aria-selected={highlightIndex === i}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm {highlightIndex === i
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50'}"
|
||||
onmousedown={(e: MouseEvent) => { e.preventDefault(); select(opt); }}
|
||||
onmouseenter={() => (highlightIndex = i)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-3 py-2 text-sm text-gray-400">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
message = '',
|
||||
type = 'success' as 'success' | 'error'
|
||||
}: {
|
||||
message?: string;
|
||||
type?: 'success' | 'error';
|
||||
} = $props();
|
||||
|
||||
let remaining = $state(5);
|
||||
let visible = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
remaining = 5;
|
||||
const timer = setInterval(() => {
|
||||
remaining -= 1;
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (remaining <= 0) {
|
||||
visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
role="alert"
|
||||
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 ===
|
||||
'success'
|
||||
? '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>
|
||||
{/if}
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,72 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
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' } = $props<{ initialContent: string; name?: string }>();
|
||||
let { initialContent = '', name = 'content' }: { initialContent?: string; name?: string } =
|
||||
$props();
|
||||
|
||||
let element: HTMLElement;
|
||||
let editor: Editor;
|
||||
let content = $state('');
|
||||
let element: HTMLElement;
|
||||
let editor: Editor;
|
||||
let content = $state('');
|
||||
|
||||
onMount(() => {
|
||||
content = initialContent;
|
||||
onMount(() => {
|
||||
content = initialContent;
|
||||
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [StarterKit],
|
||||
content: initialContent,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'outline-none h-full'
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
content = editor.getHTML();
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
return () => editor.destroy();
|
||||
});
|
||||
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 rounded-lg overflow-hidden">
|
||||
<div class="flex gap-1 p-2 bg-gray-100 border-b border-gray-300">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editor.chain().focus().toggleBold().run()}
|
||||
class="px-3 py-1 rounded font-bold hover:bg-gray-200 transition-colors"
|
||||
>B</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editor.chain().focus().toggleItalic().run()}
|
||||
class="px-3 py-1 rounded italic hover:bg-gray-200 transition-colors"
|
||||
>I</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editor.chain().focus().toggleStrike().run()}
|
||||
class="px-3 py-1 rounded line-through hover:bg-gray-200 transition-colors"
|
||||
>S</button>
|
||||
<div class="w-px bg-gray-300 mx-1"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
class="px-3 py-1 rounded hover:bg-gray-200 transition-colors"
|
||||
>• List</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
class="px-3 py-1 rounded hover:bg-gray-200 transition-colors"
|
||||
>1. List</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
role="textbox"
|
||||
tabindex="-1"
|
||||
class="p-3 min-h-40 prose prose-sm max-w-none cursor-text [&_.tiptap]:outline-none [&_.tiptap]:h-full [&_.tiptap_p]:my-1 [&_.tiptap_p:first-child]:mt-0 [&_.tiptap_p:last-child]:mb-0"
|
||||
onmousedown={() => editor?.commands.focus()}
|
||||
></div>
|
||||
<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} />
|
||||
<input type="hidden" {name} value={content} />
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { canAccessAdmin } from '$lib/svelte-admin/roles';
|
||||
|
||||
let { userRoleId = null }: { userRoleId: number | null } = $props();
|
||||
const session = authClient.useSession();
|
||||
let isAdmin = $derived(canAccessAdmin(userRoleId));
|
||||
</script>
|
||||
|
||||
<nav class="flex w-full justify-between px-8 py-6">
|
||||
<span><a href="/">Rhema Wave Technologies</a></span>
|
||||
{#if $session.data}
|
||||
<div>
|
||||
{#if isAdmin}
|
||||
<a href="/admin" class="mx-2">Admin Panel</a>
|
||||
{/if}
|
||||
<a href="/dashboard" class="mx-2">Dashboard</a>
|
||||
<button
|
||||
onclick={async () => {
|
||||
authClient.signOut();
|
||||
}}>Log Out</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span><a href="/login">Log In</a> | <a href="/register">Register</a></span>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -1,84 +1,94 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* Use it to get access to models, enums, and input types.
|
||||
*
|
||||
*
|
||||
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||
* See `client.ts` for the standard, server-side entry point.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums.ts'
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser.ts';
|
||||
export { Prisma };
|
||||
export * as $Enums from './enums.ts';
|
||||
export * from './enums.ts';
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
export type User = Prisma.UserModel;
|
||||
/**
|
||||
* Model Profile
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Profile = Prisma.ProfileModel
|
||||
export type Profile = Prisma.ProfileModel;
|
||||
/**
|
||||
* Model Address
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Address = Prisma.AddressModel
|
||||
export type Address = Prisma.AddressModel;
|
||||
/**
|
||||
* Model Session
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Session = Prisma.SessionModel
|
||||
export type Session = Prisma.SessionModel;
|
||||
/**
|
||||
* Model Account
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Account = Prisma.AccountModel
|
||||
export type Account = Prisma.AccountModel;
|
||||
/**
|
||||
* Model UserRole
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type UserRole = Prisma.UserRoleModel
|
||||
export type UserRole = Prisma.UserRoleModel;
|
||||
/**
|
||||
* Model Verification
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Verification = Prisma.VerificationModel
|
||||
export type Verification = Prisma.VerificationModel;
|
||||
/**
|
||||
* Model Proclamation
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Proclamation = Prisma.ProclamationModel
|
||||
export type Proclamation = Prisma.ProclamationModel;
|
||||
/**
|
||||
* Model UserProclamations
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type UserProclamations = Prisma.UserProclamationsModel
|
||||
export type UserProclamations = Prisma.UserProclamationsModel;
|
||||
/**
|
||||
* Model SolfeggioFrequency
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
|
||||
/**
|
||||
* Model ProclamationCategory
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
|
||||
/**
|
||||
* Model ProclamationCategorySubscriptionLevel
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategorySubscriptionLevel =
|
||||
Prisma.ProclamationCategorySubscriptionLevelModel;
|
||||
/**
|
||||
* Model LicenseKey
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
export type LicenseKey = Prisma.LicenseKeyModel;
|
||||
/**
|
||||
* Model ApiKey
|
||||
*
|
||||
*/
|
||||
export type ApiKey = Prisma.ApiKeyModel;
|
||||
/**
|
||||
* Model SubscriptionLevel
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
|
||||
export type SubscriptionLevel = Prisma.SubscriptionLevelModel;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||
@@ -10,97 +9,114 @@
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
|
||||
import * as process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.ts"
|
||||
import * as $Class from "./internal/class.ts"
|
||||
import * as Prisma from "./internal/prismaNamespace.ts"
|
||||
import * as runtime from '@prisma/client/runtime/client';
|
||||
import * as $Enums from './enums.ts';
|
||||
import * as $Class from './internal/class.ts';
|
||||
import * as Prisma from './internal/prismaNamespace.ts';
|
||||
|
||||
export * as $Enums from './enums.ts'
|
||||
export * from "./enums.ts"
|
||||
export * as $Enums from './enums.ts';
|
||||
export * from './enums.ts';
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient()
|
||||
* const prisma = new PrismaClient({
|
||||
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||
* })
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* Read more in our [docs](https://pris.ly/d/client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
export { Prisma }
|
||||
export const PrismaClient = $Class.getPrismaClientClass();
|
||||
export type PrismaClient<
|
||||
LogOpts extends Prisma.LogLevel = never,
|
||||
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Prisma.PrismaClientOptions['omit'],
|
||||
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
|
||||
> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
|
||||
export { Prisma };
|
||||
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
export type User = Prisma.UserModel;
|
||||
/**
|
||||
* Model Profile
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Profile = Prisma.ProfileModel
|
||||
export type Profile = Prisma.ProfileModel;
|
||||
/**
|
||||
* Model Address
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Address = Prisma.AddressModel
|
||||
export type Address = Prisma.AddressModel;
|
||||
/**
|
||||
* Model Session
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Session = Prisma.SessionModel
|
||||
export type Session = Prisma.SessionModel;
|
||||
/**
|
||||
* Model Account
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Account = Prisma.AccountModel
|
||||
export type Account = Prisma.AccountModel;
|
||||
/**
|
||||
* Model UserRole
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type UserRole = Prisma.UserRoleModel
|
||||
export type UserRole = Prisma.UserRoleModel;
|
||||
/**
|
||||
* Model Verification
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Verification = Prisma.VerificationModel
|
||||
export type Verification = Prisma.VerificationModel;
|
||||
/**
|
||||
* Model Proclamation
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type Proclamation = Prisma.ProclamationModel
|
||||
export type Proclamation = Prisma.ProclamationModel;
|
||||
/**
|
||||
* Model UserProclamations
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type UserProclamations = Prisma.UserProclamationsModel
|
||||
export type UserProclamations = Prisma.UserProclamationsModel;
|
||||
/**
|
||||
* Model SolfeggioFrequency
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
|
||||
/**
|
||||
* Model ProclamationCategory
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
|
||||
/**
|
||||
* Model ProclamationCategorySubscriptionLevel
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategorySubscriptionLevel =
|
||||
Prisma.ProclamationCategorySubscriptionLevelModel;
|
||||
/**
|
||||
* Model LicenseKey
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
export type LicenseKey = Prisma.LicenseKeyModel;
|
||||
/**
|
||||
* Model ApiKey
|
||||
*
|
||||
*/
|
||||
export type ApiKey = Prisma.ApiKeyModel;
|
||||
/**
|
||||
* Model SubscriptionLevel
|
||||
*
|
||||
*
|
||||
*/
|
||||
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
|
||||
export type SubscriptionLevel = Prisma.SubscriptionLevelModel;
|
||||
|
||||
@@ -1,449 +1,402 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.ts"
|
||||
import type * as Prisma from "./internal/prismaNamespace.ts"
|
||||
|
||||
import type * as runtime from '@prisma/client/runtime/client';
|
||||
import * as $Enums from './enums.ts';
|
||||
import type * as Prisma from './internal/prismaNamespace.ts';
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
|
||||
};
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
|
||||
};
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
|
||||
};
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
|
||||
};
|
||||
|
||||
export type IntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
|
||||
};
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
sort: Prisma.SortOrder;
|
||||
nulls?: Prisma.NullsOrder;
|
||||
};
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
|
||||
};
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
|
||||
};
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type FloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
|
||||
};
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
|
||||
};
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
|
||||
};
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
|
||||
};
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
|
||||
};
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
|
||||
};
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null;
|
||||
};
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
|
||||
};
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
};
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
// This file is empty because there are no enums in the schema.
|
||||
export {}
|
||||
export {};
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* WARNING: This is an internal file that is subject to change!
|
||||
*
|
||||
@@ -15,245 +14,266 @@
|
||||
* model files in the `model` directory!
|
||||
*/
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||
import * as runtime from '@prisma/client/runtime/index-browser';
|
||||
|
||||
export type * from '../models.ts'
|
||||
export type * from './prismaNamespace.ts'
|
||||
|
||||
export const Decimal = runtime.Decimal
|
||||
export type * from '../models.ts';
|
||||
export type * from './prismaNamespace.ts';
|
||||
|
||||
export const Decimal = runtime.Decimal;
|
||||
|
||||
export const NullTypes = {
|
||||
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||
}
|
||||
DbNull: runtime.NullTypes.DbNull as new (secret: never) => typeof runtime.DbNull,
|
||||
JsonNull: runtime.NullTypes.JsonNull as new (secret: never) => typeof runtime.JsonNull,
|
||||
AnyNull: runtime.NullTypes.AnyNull as new (secret: never) => typeof runtime.AnyNull
|
||||
};
|
||||
/**
|
||||
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const DbNull = runtime.DbNull
|
||||
export const DbNull = runtime.DbNull;
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const JsonNull = runtime.JsonNull
|
||||
export const JsonNull = runtime.JsonNull;
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const AnyNull = runtime.AnyNull
|
||||
|
||||
export const AnyNull = runtime.AnyNull;
|
||||
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
Profile: 'Profile',
|
||||
Address: 'Address',
|
||||
Session: 'Session',
|
||||
Account: 'Account',
|
||||
UserRole: 'UserRole',
|
||||
Verification: 'Verification',
|
||||
Proclamation: 'Proclamation',
|
||||
UserProclamations: 'UserProclamations',
|
||||
SolfeggioFrequency: 'SolfeggioFrequency',
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
LicenseKey: 'LicenseKey',
|
||||
SubscriptionLevel: 'SubscriptionLevel'
|
||||
} as const
|
||||
User: 'User',
|
||||
Profile: 'Profile',
|
||||
Address: 'Address',
|
||||
Session: 'Session',
|
||||
Account: 'Account',
|
||||
UserRole: 'UserRole',
|
||||
Verification: 'Verification',
|
||||
Proclamation: 'Proclamation',
|
||||
UserProclamations: 'UserProclamations',
|
||||
SolfeggioFrequency: 'SolfeggioFrequency',
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
SubscriptionLevel: 'SubscriptionLevel'
|
||||
} as const;
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName];
|
||||
|
||||
/*
|
||||
* Enums
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = {
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const);
|
||||
|
||||
export type TransactionIsolationLevel =
|
||||
(typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel];
|
||||
|
||||
export const UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
roleId: 'roleId'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
roleId: 'roleId'
|
||||
} as const;
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum];
|
||||
|
||||
export const ProfileScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
phoneNumber: 'phoneNumber'
|
||||
} as const
|
||||
|
||||
export type ProfileScalarFieldEnum = (typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum]
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
phoneNumber: 'phoneNumber',
|
||||
ghlContactId: 'ghlContactId'
|
||||
} as const;
|
||||
|
||||
export type ProfileScalarFieldEnum =
|
||||
(typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum];
|
||||
|
||||
export const AddressScalarFieldEnum = {
|
||||
id: 'id',
|
||||
profileId: 'profileId',
|
||||
street: 'street',
|
||||
city: 'city',
|
||||
state: 'state',
|
||||
postalCode: 'postalCode',
|
||||
country: 'country'
|
||||
} as const
|
||||
|
||||
export type AddressScalarFieldEnum = (typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum]
|
||||
id: 'id',
|
||||
profileId: 'profileId',
|
||||
street: 'street',
|
||||
city: 'city',
|
||||
state: 'state',
|
||||
postalCode: 'postalCode',
|
||||
country: 'country',
|
||||
type: 'type',
|
||||
isDefaultMailing: 'isDefaultMailing',
|
||||
isDefaultBilling: 'isDefaultBilling',
|
||||
isDefaultShipping: 'isDefaultShipping'
|
||||
} as const;
|
||||
|
||||
export type AddressScalarFieldEnum =
|
||||
(typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum];
|
||||
|
||||
export const SessionScalarFieldEnum = {
|
||||
id: 'id',
|
||||
expiresAt: 'expiresAt',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
|
||||
id: 'id',
|
||||
expiresAt: 'expiresAt',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
userId: 'userId'
|
||||
} as const;
|
||||
|
||||
export type SessionScalarFieldEnum =
|
||||
(typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum];
|
||||
|
||||
export const AccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
accountId: 'accountId',
|
||||
providerId: 'providerId',
|
||||
userId: 'userId',
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'refreshToken',
|
||||
idToken: 'idToken',
|
||||
accessTokenExpiresAt: 'accessTokenExpiresAt',
|
||||
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
|
||||
scope: 'scope',
|
||||
password: 'password',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
|
||||
id: 'id',
|
||||
accountId: 'accountId',
|
||||
providerId: 'providerId',
|
||||
userId: 'userId',
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'refreshToken',
|
||||
idToken: 'idToken',
|
||||
accessTokenExpiresAt: 'accessTokenExpiresAt',
|
||||
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
|
||||
scope: 'scope',
|
||||
password: 'password',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const;
|
||||
|
||||
export type AccountScalarFieldEnum =
|
||||
(typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum];
|
||||
|
||||
export const UserRoleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name'
|
||||
} as const
|
||||
|
||||
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
|
||||
id: 'id',
|
||||
name: 'name'
|
||||
} as const;
|
||||
|
||||
export type UserRoleScalarFieldEnum =
|
||||
(typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum];
|
||||
|
||||
export const VerificationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
identifier: 'identifier',
|
||||
value: 'value',
|
||||
expiresAt: 'expiresAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
|
||||
id: 'id',
|
||||
identifier: 'identifier',
|
||||
value: 'value',
|
||||
expiresAt: 'expiresAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const;
|
||||
|
||||
export type VerificationScalarFieldEnum =
|
||||
(typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum];
|
||||
|
||||
export const ProclamationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
content: 'content',
|
||||
solfeggioFrequencyId: 'solfeggioFrequencyId',
|
||||
categoryId: 'categoryId',
|
||||
price: 'price',
|
||||
subscriptionLevelId: 'subscriptionLevelId'
|
||||
} as const
|
||||
|
||||
export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
content: 'content',
|
||||
solfeggioFrequencyId: 'solfeggioFrequencyId',
|
||||
categoryId: 'categoryId',
|
||||
subscriptionLevelId: 'subscriptionLevelId'
|
||||
} as const;
|
||||
|
||||
export type ProclamationScalarFieldEnum =
|
||||
(typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum];
|
||||
|
||||
export const UserProclamationsScalarFieldEnum = {
|
||||
userId: 'userId',
|
||||
proclamationId: 'proclamationId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type UserProclamationsScalarFieldEnum = (typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum]
|
||||
userId: 'userId',
|
||||
proclamationId: 'proclamationId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const;
|
||||
|
||||
export type UserProclamationsScalarFieldEnum =
|
||||
(typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum];
|
||||
|
||||
export const SolfeggioFrequencyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
value: 'value'
|
||||
} as const
|
||||
|
||||
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
value: 'value'
|
||||
} as const;
|
||||
|
||||
export type SolfeggioFrequencyScalarFieldEnum =
|
||||
(typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum];
|
||||
|
||||
export const ProclamationCategoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
parentId: 'parentId'
|
||||
} as const
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
sortOrder: 'sortOrder',
|
||||
parentId: 'parentId'
|
||||
} as const;
|
||||
|
||||
export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
|
||||
export type ProclamationCategoryScalarFieldEnum =
|
||||
(typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum];
|
||||
|
||||
export const ProclamationCategorySubscriptionLevelScalarFieldEnum = {
|
||||
categoryId: 'categoryId',
|
||||
subscriptionLevelId: 'subscriptionLevelId'
|
||||
} as const;
|
||||
|
||||
export type ProclamationCategorySubscriptionLevelScalarFieldEnum =
|
||||
(typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum];
|
||||
|
||||
export const LicenseKeyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
active: 'active',
|
||||
level: 'level',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
active: 'active',
|
||||
level: 'level',
|
||||
userId: 'userId'
|
||||
} as const;
|
||||
|
||||
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
||||
export type LicenseKeyScalarFieldEnum =
|
||||
(typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum];
|
||||
|
||||
export const ApiKeyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
keyPrefix: 'keyPrefix',
|
||||
keyHash: 'keyHash',
|
||||
createdAt: 'createdAt',
|
||||
expiresAt: 'expiresAt',
|
||||
revokedAt: 'revokedAt'
|
||||
} as const;
|
||||
|
||||
export type ApiKeyScalarFieldEnum =
|
||||
(typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum];
|
||||
|
||||
export const SubscriptionLevelScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description'
|
||||
} as const
|
||||
|
||||
export type SubscriptionLevelScalarFieldEnum = (typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum]
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description'
|
||||
} as const;
|
||||
|
||||
export type SubscriptionLevelScalarFieldEnum =
|
||||
(typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum];
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const;
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder];
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const;
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const;
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder];
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This is a barrel export file for all models and their related types.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
export type * from './models/User.ts'
|
||||
export type * from './models/Profile.ts'
|
||||
export type * from './models/Address.ts'
|
||||
export type * from './models/Session.ts'
|
||||
export type * from './models/Account.ts'
|
||||
export type * from './models/UserRole.ts'
|
||||
export type * from './models/Verification.ts'
|
||||
export type * from './models/Proclamation.ts'
|
||||
export type * from './models/UserProclamations.ts'
|
||||
export type * from './models/SolfeggioFrequency.ts'
|
||||
export type * from './models/ProclamationCategory.ts'
|
||||
export type * from './models/LicenseKey.ts'
|
||||
export type * from './models/SubscriptionLevel.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
export type * from './models/User.ts';
|
||||
export type * from './models/Profile.ts';
|
||||
export type * from './models/Address.ts';
|
||||
export type * from './models/Session.ts';
|
||||
export type * from './models/Account.ts';
|
||||
export type * from './models/UserRole.ts';
|
||||
export type * from './models/Verification.ts';
|
||||
export type * from './models/Proclamation.ts';
|
||||
export type * from './models/UserProclamations.ts';
|
||||
export type * from './models/SolfeggioFrequency.ts';
|
||||
export type * from './models/ProclamationCategory.ts';
|
||||
export type * from './models/ProclamationCategorySubscriptionLevel.ts';
|
||||
export type * from './models/LicenseKey.ts';
|
||||
export type * from './models/ApiKey.ts';
|
||||
export type * from './models/SubscriptionLevel.ts';
|
||||
export type * from './commonInputTypes.ts';
|
||||
|
||||
+1701
-1422
File diff suppressed because it is too large
Load Diff
+1619
-1210
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1514
-1200
File diff suppressed because it is too large
Load Diff
+1504
-1245
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2299
-1870
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1301
-1058
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+52
-48
@@ -1,71 +1,75 @@
|
||||
// Contains all payment related functions such as making payments,
|
||||
// issueing refunds, storing and deleting credit cards, and more.
|
||||
// A full list of implementation requirements will be kept below.
|
||||
//
|
||||
// issueing refunds, storing and deleting credit cards, and more.
|
||||
// A full list of implementation requirements will be kept below.
|
||||
//
|
||||
// ---- Implementations ----
|
||||
// ✔ Make a payment...
|
||||
// ✔ With a non-saved card
|
||||
// ✔ With a saved card
|
||||
// ✔ Add a card
|
||||
// ✔ Delete a card
|
||||
// ✔ Delete a card
|
||||
// ✔ Issue a refund
|
||||
|
||||
import type { Decimal } from "@prisma/client/runtime/client";
|
||||
import type { Decimal } from '@prisma/client/runtime/client';
|
||||
|
||||
export interface CreditDebitCard {
|
||||
first_name: string,
|
||||
last_name: string,
|
||||
card_number: number,
|
||||
expiration_month: number,
|
||||
expiration_year: number,
|
||||
security_code: number,
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
card_number: number;
|
||||
expiration_month: number;
|
||||
expiration_year: number;
|
||||
security_code: number;
|
||||
}
|
||||
|
||||
export async function chargeCreditDebitCard (card: CreditDebitCard, amount: Decimal): Promise<null> {
|
||||
/**
|
||||
* chargeCreditDebitCard attempts to request payment from a valid card,
|
||||
* for the amount specified.
|
||||
*/
|
||||
console.log(`charged card ${card} for $${amount}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return null;
|
||||
export async function chargeCreditDebitCard(card: CreditDebitCard, amount: Decimal): Promise<null> {
|
||||
/**
|
||||
* chargeCreditDebitCard attempts to request payment from a valid card,
|
||||
* for the amount specified.
|
||||
*/
|
||||
console.log(`charged card ${card} for $${amount}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function chargedStoredCard(cardNumber: number, profileID: string, amount: Decimal): Promise<null> {
|
||||
/**
|
||||
* chargedStoredCard attempts to request payment from a valid card,
|
||||
* for the amount specified.
|
||||
* The card is identified by the last four digits of the card
|
||||
* number, and the profile ID.
|
||||
* NOTE: This should be enough to identify a saved card, but the
|
||||
* function signature may change in the future.
|
||||
*/
|
||||
console.log(`charged ${profileID}'s card ${cardNumber} for ${cardNumber} $${amount}`);
|
||||
return null;
|
||||
export async function chargedStoredCard(
|
||||
cardNumber: number,
|
||||
profileID: string,
|
||||
amount: Decimal
|
||||
): Promise<null> {
|
||||
/**
|
||||
* chargedStoredCard attempts to request payment from a valid card,
|
||||
* for the amount specified.
|
||||
* The card is identified by the last four digits of the card
|
||||
* number, and the profile ID.
|
||||
* NOTE: This should be enough to identify a saved card, but the
|
||||
* function signature may change in the future.
|
||||
*/
|
||||
console.log(`charged ${profileID}'s card ${cardNumber} for ${cardNumber} $${amount}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function deleteCardFromProfile(cardNumber: number, profileID: string): Promise<null> {
|
||||
/**
|
||||
* deleteCardFromProfile deletes a stored card on a user's profile.
|
||||
*/
|
||||
console.log(`Deleted card ${cardNumber} from ${profileID}'s profile.`);
|
||||
return null;
|
||||
/**
|
||||
* deleteCardFromProfile deletes a stored card on a user's profile.
|
||||
*/
|
||||
console.log(`Deleted card ${cardNumber} from ${profileID}'s profile.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function storeCardToProfile(card: CreditDebitCard, profileID: string): Promise<null> {
|
||||
/**
|
||||
* storeCardToProfile stores a new card to a user's profile
|
||||
* for easy re-use later.
|
||||
*/
|
||||
console.log(`Deleted card ${card.card_number} from ${profileID}'s profile.`);
|
||||
return null;
|
||||
/**
|
||||
* storeCardToProfile stores a new card to a user's profile
|
||||
* for easy re-use later.
|
||||
*/
|
||||
console.log(`Deleted card ${card.card_number} from ${profileID}'s profile.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function requestRefund(card: CreditDebitCard, amount: Decimal): Promise<null> {
|
||||
/**
|
||||
* requestRefund attempts to process a refund for the amount to the
|
||||
* provided card.
|
||||
*/
|
||||
console.log(`Refunded ${amount} to ${card.card_number}`);
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* requestRefund attempts to process a refund for the amount to the
|
||||
* provided card.
|
||||
*/
|
||||
console.log(`Refunded ${amount} to ${card.card_number}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
export type ApiKeyRecord = {
|
||||
id: string;
|
||||
keyPrefix: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
};
|
||||
|
||||
export type GeneratedKey = {
|
||||
plaintext: string;
|
||||
prefix: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export function generateApiKey(): GeneratedKey {
|
||||
const raw = randomBytes(32);
|
||||
const hex = raw.toString('hex');
|
||||
const plaintext = `sk_${hex}`;
|
||||
const prefix = plaintext.slice(0, 11);
|
||||
const hash = createHash('sha256').update(plaintext).digest('hex');
|
||||
return { plaintext, prefix, hash };
|
||||
}
|
||||
|
||||
export async function validateApiKey(key: string): Promise<ApiKeyRecord | null> {
|
||||
const hash = createHash('sha256').update(key).digest('hex');
|
||||
const record = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
|
||||
if (!record) return null;
|
||||
if (record.revokedAt && record.revokedAt <= new Date()) return null;
|
||||
if (record.expiresAt && record.expiresAt <= new Date()) return null;
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function rotateApiKey(emergency = false): Promise<{
|
||||
plaintext: string;
|
||||
prefix: string;
|
||||
previousExpiresAt: Date | null;
|
||||
previousRevokedAt: Date | null;
|
||||
}> {
|
||||
const now = new Date();
|
||||
|
||||
const activeKeys = await prisma.apiKey.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
|
||||
}
|
||||
});
|
||||
|
||||
let previousExpiresAt: Date | null = null;
|
||||
let previousRevokedAt: Date | null = null;
|
||||
|
||||
if (emergency) {
|
||||
previousRevokedAt = now;
|
||||
for (const key of activeKeys) {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: key.id },
|
||||
data: { revokedAt: now }
|
||||
});
|
||||
}
|
||||
previousExpiresAt = null;
|
||||
} else {
|
||||
const graceEnd = new Date(now.getTime() + 48 * 60 * 60 * 1000);
|
||||
previousExpiresAt = graceEnd;
|
||||
for (const key of activeKeys) {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: key.id },
|
||||
data: { expiresAt: graceEnd }
|
||||
});
|
||||
}
|
||||
previousRevokedAt = null;
|
||||
}
|
||||
|
||||
const { plaintext, prefix, hash } = generateApiKey();
|
||||
await prisma.apiKey.create({
|
||||
data: { keyPrefix: prefix, keyHash: hash }
|
||||
});
|
||||
|
||||
return { plaintext, prefix, previousExpiresAt, previousRevokedAt };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { sendMail } = vi.hoisted(() => ({
|
||||
sendMail: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$env/static/private', () => ({
|
||||
BETTER_AUTH_URL: 'http://localhost:5173'
|
||||
}));
|
||||
|
||||
vi.mock('./resend', () => ({
|
||||
sendMail
|
||||
}));
|
||||
|
||||
import { sendVerificationEmail } from './email-verification';
|
||||
|
||||
describe('sendVerificationEmail', () => {
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
});
|
||||
|
||||
it('calls sendMail with correct email and template', async () => {
|
||||
sendMail.mockResolvedValue(undefined);
|
||||
|
||||
await sendVerificationEmail({
|
||||
email: 'jane@example.com',
|
||||
token: 'abc123'
|
||||
});
|
||||
|
||||
expect(sendMail).toHaveBeenCalledWith({
|
||||
to: 'jane@example.com',
|
||||
subject: 'Verify your email address',
|
||||
templateId: 'email-verification-email',
|
||||
data: {
|
||||
target_email_address: 'jane@example.com',
|
||||
verification_link:
|
||||
'http://localhost:5173/api/auth/verify-email?token=abc123&callbackURL=/login?verified=true'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('constructs verification URL with the given token', async () => {
|
||||
sendMail.mockResolvedValue(undefined);
|
||||
|
||||
await sendVerificationEmail({
|
||||
email: 'test@example.com',
|
||||
token: 'xyz789'
|
||||
});
|
||||
|
||||
const call = sendMail.mock.calls[0][0];
|
||||
expect(call.data.verification_link).toContain('xyz789');
|
||||
expect(call.data.verification_link).toContain('/login?verified=true');
|
||||
});
|
||||
|
||||
it('throws when sendMail throws', async () => {
|
||||
sendMail.mockRejectedValue(new Error('Resend error'));
|
||||
|
||||
await expect(
|
||||
sendVerificationEmail({
|
||||
email: 'jane@example.com',
|
||||
token: 'abc123'
|
||||
})
|
||||
).rejects.toThrow('Resend error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BETTER_AUTH_URL } from '$env/static/private';
|
||||
import { sendMail } from './resend';
|
||||
|
||||
export async function sendVerificationEmail(options: { email: string; token: string }) {
|
||||
const verificationUrl = `${BETTER_AUTH_URL}/api/auth/verify-email?token=${options.token}&callbackURL=/login?verified=true`;
|
||||
|
||||
await sendMail({
|
||||
to: options.email,
|
||||
subject: 'Verify your email address',
|
||||
templateId: 'email-verification-email',
|
||||
data: {
|
||||
target_email_address: options.email,
|
||||
verification_link: verificationUrl
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** src/lib/server/resend.ts
|
||||
* AUTHOR: Neil Clack
|
||||
* EMAIL: nclack@radiantwavetech.com
|
||||
* DATE: 07/06/2027
|
||||
*
|
||||
* A server side library and client for Resend
|
||||
* Includes the Resend client, as well as functions
|
||||
* for different email scenarios.
|
||||
*
|
||||
*/
|
||||
import { Resend } from 'resend';
|
||||
import { RESEND_API_KEY, EMAIL_FROM, RESEND_TEMPLATE_ID } from '$env/static/private';
|
||||
|
||||
export const resend = new Resend(RESEND_API_KEY);
|
||||
|
||||
/**
|
||||
* Send a transactional email using a Resend-hosted template.
|
||||
*
|
||||
* @example
|
||||
* await sendMail({
|
||||
* to: 'jane@example.com',
|
||||
* templateId: 'tmpl_abc123', // optional — defaults to RESEND_TEMPLATE_ID
|
||||
* subject: 'Your license key is ready', // optional — defaults to 'Template Email'
|
||||
* data: { // optional — template variables
|
||||
* firstName: 'Jane',
|
||||
* licenseKey: 'RWT-7X9K-2M4Q'
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* @returns The Resend API response data on success.
|
||||
* @throws {Error} If the Resend API call fails.
|
||||
*/
|
||||
export async function sendMail(options: {
|
||||
to: string;
|
||||
subject?: string;
|
||||
templateId?: string;
|
||||
data?: Record<string, string | number>;
|
||||
}) {
|
||||
const { to, subject, templateId, data } = options;
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: EMAIL_FROM,
|
||||
to: [to],
|
||||
subject: subject ?? 'Template Email',
|
||||
template: {
|
||||
id: templateId ?? RESEND_TEMPLATE_ID,
|
||||
variables: data ?? {}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
+130
-109
@@ -1,8 +1,8 @@
|
||||
# svelte-admin
|
||||
|
||||
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
|
||||
registry and get full CRUD UI with dynamic routing, role-based access control, searchable
|
||||
dropdowns, and related-record panels — no per-model route files needed.
|
||||
registry and get full CRUD UI with per-model static pages, role-based access control, searchable
|
||||
tables, FK dropdowns, TipTap editor for rich text, and related-record panels.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -13,27 +13,30 @@ src/lib/svelte-admin/ # Framework core
|
||||
├── roles.ts # Role hierarchy and access helpers
|
||||
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
|
||||
├── introspect.ts # Field label generation and dot-notation resolution
|
||||
├── form-data.ts # FormData → typed object parser
|
||||
├── form-data.ts # FormData -> typed object parser
|
||||
├── crud.ts # CRUD action factory (load, upsert, delete)
|
||||
└── index.ts # Barrel exports
|
||||
|
||||
src/routes/(protected)/admin/ # Dynamic admin routes
|
||||
src/routes/(protected)/admin/ # Per-model admin pages (no dynamic [model] routing)
|
||||
├── +layout.server.ts # Role-based access guard
|
||||
├── +layout.svelte # Layout shell
|
||||
├── +page.server.ts # Admin home (model grid)
|
||||
├── +page.svelte # Admin home UI
|
||||
├── [model]/
|
||||
│ ├── +page.server.ts # List view + create action
|
||||
│ ├── +page.svelte # List view UI
|
||||
│ └── [id]/
|
||||
│ ├── +page.server.ts # Edit view + update/delete actions
|
||||
│ └── +page.svelte # Edit view UI
|
||||
├── users/ # Each model gets its own page directory
|
||||
├── profiles/
|
||||
├── addresses/
|
||||
├── user-roles/
|
||||
├── proclamations/
|
||||
├── solfeggio-frequencies/
|
||||
├── proclamation-categories/
|
||||
├── subscription-levels/
|
||||
└── license-keys/ # listOnly — no create/edit modal
|
||||
|
||||
src/lib/components/admin/ # Reusable admin UI components
|
||||
├── AdminTable.svelte # Sortable, searchable data table
|
||||
├── AdminForm.svelte # Dynamic form with type-aware inputs
|
||||
├── ComboBox.svelte # Searchable dropdown for FK fields
|
||||
├── RelatedHasOne.svelte # Display panel for 1:1 related records
|
||||
└── RelatedHasMany.svelte # Display table for 1:N related records
|
||||
├── AdminModal.svelte # Modal overlay for CRUD forms
|
||||
├── TipTap.svelte # Rich-text editor (in components/tiptap/)
|
||||
```
|
||||
|
||||
## Adding a New Model
|
||||
@@ -74,10 +77,11 @@ src/lib/components/admin/ # Reusable admin UI components
|
||||
}
|
||||
```
|
||||
|
||||
3. **Done.** Visit `/admin/widgets`. The dynamic `[model]` routes handle list, create, edit,
|
||||
and delete automatically.
|
||||
3. **Create the page directory** `src/routes/(protected)/admin/widgets/` with:
|
||||
- `+page.server.ts` — load records + form actions using `crud.ts` helpers
|
||||
- `+page.svelte` — render AdminTable + AdminModal with AdminForm
|
||||
|
||||
No new route files, no new components. The registry is the only thing you touch.
|
||||
4. **Done.** Visit `/admin/widgets`. The page handles list, create, edit via modal.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
@@ -95,25 +99,26 @@ No new route files, no new components. The registry is the only thing you touch.
|
||||
| `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
|
||||
| `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
|
||||
| `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
|
||||
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table. |
|
||||
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table and no modal. |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
|
||||
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as ComboBox dropdowns. |
|
||||
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages. |
|
||||
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as `<select>` dropdowns. |
|
||||
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages (display only via related records table). |
|
||||
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
|
||||
|
||||
### PrismaScalarType
|
||||
|
||||
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
|
||||
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap'`
|
||||
|
||||
Controls both form input rendering and FormData parsing:
|
||||
|
||||
| Type | HTML Input | Parse Logic |
|
||||
| ---------- | ----------------------------------- | ------------------------------ |
|
||||
| `String` | `<input type="text">` | `raw.toString()` |
|
||||
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty → `null` |
|
||||
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty → `null` |
|
||||
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` |
|
||||
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty → `null` |
|
||||
| Type | HTML Input | Parse Logic |
|
||||
| ---------- | -------------------------------------- | ------------------------------- |
|
||||
| `String` | `<input type="text">` | `raw.toString()` |
|
||||
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty -> `null` |
|
||||
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty -> `null` |
|
||||
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` -> `true` |
|
||||
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty -> `null` |
|
||||
| `Tiptap` | TipTap rich-text editor (hidden input) | `raw.toString()` |
|
||||
|
||||
### RelationOptionConfig
|
||||
|
||||
@@ -142,14 +147,18 @@ Controls both form input rendering and FormData parsing:
|
||||
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
|
||||
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
|
||||
|
||||
When a field also has a `relationOptions` entry, the framework renders a `ComboBox` (searchable
|
||||
dropdown) instead of the default input for that type.
|
||||
When a field also has a `relationOptions` entry, the framework renders a `<select>` dropdown
|
||||
instead of the default input for that type. The options are fetched server-side by `crud.ts`
|
||||
and passed to the client as `relationOptionsData`.
|
||||
|
||||
For `Tiptap` fields, the TipTap rich-text editor is rendered. The editor content is stored in a
|
||||
hidden input and submitted with the form.
|
||||
|
||||
## Relations
|
||||
|
||||
### Foreign Key Dropdowns (relationOptions)
|
||||
|
||||
When a form field is a foreign key, add a `relationOptions` entry to render a searchable ComboBox:
|
||||
When a form field is a foreign key, add a `relationOptions` entry to render a `<select>` dropdown:
|
||||
|
||||
```ts
|
||||
{
|
||||
@@ -165,7 +174,8 @@ When a form field is a foreign key, add a `relationOptions` entry to render a se
|
||||
}
|
||||
```
|
||||
|
||||
The route loader automatically queries the related model and passes options to the ComboBox.
|
||||
The `crud.ts` `loadRecords` function automatically queries the related model and passes options
|
||||
to the page as `relationOptionsData`.
|
||||
|
||||
### Eager Loading (include)
|
||||
|
||||
@@ -184,25 +194,23 @@ Display has-one or has-many related records as panels on the edit page using `re
|
||||
|
||||
```ts
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'profile',
|
||||
label: 'Profile',
|
||||
type: 'has-one',
|
||||
displayFields: ['firstName', 'lastName'],
|
||||
editSlug: 'profiles' // links to /admin/profiles/{id}
|
||||
},
|
||||
{
|
||||
relationKey: 'licenseKeys',
|
||||
label: 'License Keys',
|
||||
type: 'has-many',
|
||||
displayFields: ['key', 'active', 'level']
|
||||
}
|
||||
{
|
||||
relationKey: 'profile',
|
||||
label: 'Profile',
|
||||
type: 'has-one',
|
||||
displayFields: ['firstName', 'lastName'],
|
||||
editSlug: 'profiles' // links to /admin/profiles/{id}
|
||||
},
|
||||
{
|
||||
relationKey: 'licenseKeys',
|
||||
label: 'License Keys',
|
||||
type: 'has-many',
|
||||
displayFields: ['key', 'active', 'level']
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
The `relationKey` must match a key in `include` so the data is actually loaded. `has-one` renders
|
||||
a key-value panel via `RelatedHasOne`; `has-many` renders a table via `RelatedHasMany`. Both
|
||||
support an optional `editSlug` to link to the related record's edit page.
|
||||
The `relationKey` must match a key in `include` so the data is actually loaded.
|
||||
|
||||
## Access Control
|
||||
|
||||
@@ -228,38 +236,40 @@ Use `accessFilter` to restrict which records a viewer can see:
|
||||
|
||||
```ts
|
||||
accessFilter: (viewerRoleId: number) => {
|
||||
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
|
||||
if (viewerRoleId <= ROLES.ADMIN) {
|
||||
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
|
||||
}
|
||||
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
|
||||
if (viewerRoleId <= ROLES.SUPER) return undefined;
|
||||
if (viewerRoleId <= ROLES.ADMIN) {
|
||||
return { roleId: { gte: ROLES.STAFF } };
|
||||
}
|
||||
return { roleId: { gt: ROLES.STAFF } };
|
||||
};
|
||||
```
|
||||
|
||||
## How Dynamic Routing Works
|
||||
## How Static Pages Work
|
||||
|
||||
1. User visits `/admin/proclamations` (or `/admin/proclamations/abc123`)
|
||||
2. SvelteKit matches the `[model]` (and `[id]`) route params
|
||||
3. `+page.server.ts` calls `getModelConfig(params.model)` to look up the config
|
||||
4. `getDelegate(config)` returns `prisma[lcfirst(config.modelName)]` (e.g., `prisma.proclamation`)
|
||||
5. The delegate runs `findMany` (list), `findUnique` (edit), `create`, `update`, or `delete`
|
||||
6. Data + config are passed to the Svelte page component
|
||||
7. Components (`AdminTable`, `AdminForm`, etc.) render based on config
|
||||
1. User visits `/admin/proclamations`
|
||||
2. SvelteKit matches the static route `/admin/proclamations/+page.server.ts`
|
||||
3. `+page.server.ts` calls `getModelConfig('proclamations')` to look up the config
|
||||
4. `loadRecords(config)` calls `prisma.proclamation.findMany()` with the config's `include` and `accessFilter`
|
||||
5. For each FK field with a `relationOptions`, it also queries the related model for dropdown options
|
||||
6. Data + config are passed to `+page.svelte`
|
||||
7. The page renders `AdminTable` with records and `AdminModal` + `AdminForm` for create/edit
|
||||
|
||||
## Component API
|
||||
|
||||
### AdminTable
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------- | --------------------------- | ------------------------------------ |
|
||||
| `records` | `Record<string, unknown>[]` | Data rows |
|
||||
| `listFields` | `string[]` | Column keys (dot-notation supported) |
|
||||
| `searchFields` | `string[]` | Fields for client-side search |
|
||||
| `modelSlug` | `string` | Used for building edit links |
|
||||
| `listOnly` | `boolean?` | Suppress edit links |
|
||||
| Prop | Type | Description |
|
||||
| -------------- | ---------------------------------- | ------------------------------------ |
|
||||
| `records` | `Record<string, unknown>[]` | Data rows |
|
||||
| `listFields` | `string[]` | Column keys (dot-notation supported) |
|
||||
| `searchFields` | `string[]` | Fields for client-side search |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for cell formatting |
|
||||
| `singular` | `string` | Singular label for UI text |
|
||||
| `onRowClick` | `(record) => void?` | Click handler for edit modal |
|
||||
| `onNew` | `() => void?` | Click handler for create modal |
|
||||
|
||||
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
|
||||
decimals), filtered count display.
|
||||
decimals), empty state.
|
||||
|
||||
### AdminForm
|
||||
|
||||
@@ -270,54 +280,65 @@ decimals), filtered count display.
|
||||
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
|
||||
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
|
||||
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
|
||||
| `form` | `ActionData?` | Carries validation errors |
|
||||
| `action` | `string` | Form action (e.g., `'?/create'`) |
|
||||
| `readonlyFields` | `string[]?` | Read-only field names |
|
||||
| `action` | `string` | Form action (e.g., `'?/upsert'`) |
|
||||
| `submitLabel` | `string?` | Button text |
|
||||
|
||||
### ComboBox
|
||||
### AdminModal
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------------- | -------------------- | ------------------ |
|
||||
| `id` | `string` | Element ID |
|
||||
| `name` | `string` | Form field name |
|
||||
| `options` | `{ label; value }[]` | Dropdown items |
|
||||
| `placeholder` | `string?` | Placeholder text |
|
||||
| `value` | `string \| number?` | Pre-selected value |
|
||||
| Prop | Type | Description |
|
||||
| ---------- | ------------ | ------------------ |
|
||||
| `open` | `boolean` | Show/hide modal |
|
||||
| `title` | `string` | Modal heading |
|
||||
| `onClose` | `() => void` | Close handler |
|
||||
| `children` | `Snippet` | Modal body content |
|
||||
|
||||
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape),
|
||||
clear button, scroll-to-highlight.
|
||||
### TipTap
|
||||
|
||||
### RelatedHasOne
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --------------- | --------------------------------- | ------------------------------------ |
|
||||
| `label` | `string` | Section heading |
|
||||
| `record` | `Record<string, unknown> \| null` | Related record |
|
||||
| `displayFields` | `string[]` | Fields to display as key-value pairs |
|
||||
| `editSlug` | `string?` | Links to edit the related record |
|
||||
|
||||
### RelatedHasMany
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --------------- | --------------------------- | ----------------------------- |
|
||||
| `label` | `string` | Section heading (shows count) |
|
||||
| `records` | `Record<string, unknown>[]` | Related records |
|
||||
| `displayFields` | `string[]` | Table columns |
|
||||
| `editSlug` | `string?` | Per-row edit links |
|
||||
| Prop | Type | Description |
|
||||
| ---------------- | -------- | --------------------------------- |
|
||||
| `initialContent` | `string` | Initial HTML content |
|
||||
| `name` | `string` | Hidden input name for form submit |
|
||||
|
||||
## Utility Functions
|
||||
|
||||
| Function | Module | Description |
|
||||
| ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------- |
|
||||
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` → `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
|
||||
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
|
||||
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
|
||||
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
|
||||
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
|
||||
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
|
||||
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
|
||||
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
|
||||
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
|
||||
| Function | Module | Description |
|
||||
| ------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
|
||||
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` -> `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
|
||||
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
|
||||
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
|
||||
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
|
||||
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
|
||||
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
|
||||
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
|
||||
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
|
||||
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
|
||||
| `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model |
|
||||
| `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data |
|
||||
| `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID |
|
||||
|
||||
## Models in Admin
|
||||
|
||||
| Registry Slug | Prisma Model | Notes |
|
||||
| ------------------------- | -------------------- | ----------------------------------- |
|
||||
| `users` | User | Role assignment, view-only fields |
|
||||
| `profiles` | Profile | Linked to user FK |
|
||||
| `addresses` | Address | Linked to profile FK |
|
||||
| `user-roles` | UserRole | |
|
||||
| `proclamations` | Proclamation | TipTap rich-text editor for content |
|
||||
| `solfeggio-frequencies` | SolfeggioFrequency | |
|
||||
| `proclamation-categories` | ProclamationCategory | Parent category hierarchy |
|
||||
| `license-keys` | LicenseKey | listOnly — read-only table, no CRUD |
|
||||
| `subscription-levels` | SubscriptionLevel | |
|
||||
|
||||
### Excluded from Admin
|
||||
|
||||
| Model | Reason |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| Session | Better Auth internal — not for manual editing |
|
||||
| Account | Better Auth internal |
|
||||
| Verification | Better Auth internal |
|
||||
| UserProclamations | Composite key — managed via other models |
|
||||
|
||||
## Complete Example
|
||||
|
||||
@@ -364,7 +385,7 @@ A hypothetical `Product` model showing all configuration features:
|
||||
],
|
||||
accessFilter: (viewerRoleId: number) => {
|
||||
if (viewerRoleId <= 2) return undefined;
|
||||
return { active: true }; // staff only sees active products
|
||||
return { active: true };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createLocals, mockUser } from '$lib/test-utils';
|
||||
import { getModelConfig } from './registry';
|
||||
|
||||
// Ensure superValidate can be imported from server path in Node.js/SSR context
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
|
||||
vi.mock('$lib/db', () => {
|
||||
const mkdel = () => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn()
|
||||
});
|
||||
return {
|
||||
prisma: {
|
||||
userRole: mkdel(),
|
||||
proclamation: mkdel(),
|
||||
proclamationCategory: mkdel(),
|
||||
solfeggioFrequency: mkdel(),
|
||||
subscriptionLevel: mkdel()
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { loadRecords, upsertRecord, deleteRecord } from './crud';
|
||||
|
||||
const userRoleFindMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
|
||||
const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
|
||||
const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
|
||||
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
|
||||
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
|
||||
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
|
||||
const config = getModelConfig('user-roles')!;
|
||||
|
||||
describe('crud loadRecords', () => {
|
||||
beforeEach(() => {
|
||||
userRoleFindMany.mockReset();
|
||||
});
|
||||
|
||||
it('returns records', async () => {
|
||||
userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await loadRecords(locals, config);
|
||||
|
||||
expect(result.records).toEqual([{ id: 1, name: 'Admin' }]);
|
||||
});
|
||||
|
||||
it('returns empty records array', async () => {
|
||||
userRoleFindMany.mockResolvedValue([]);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await loadRecords(locals, config);
|
||||
|
||||
expect(result.records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crud upsertRecord', () => {
|
||||
beforeEach(() => {
|
||||
create.mockReset();
|
||||
update.mockReset();
|
||||
findUnique.mockReset();
|
||||
findFirst.mockReset();
|
||||
});
|
||||
|
||||
it('creates a new record when no id', async () => {
|
||||
create.mockResolvedValue({ id: 2, name: 'Editor' });
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('name', 'Editor');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await upsertRecord(fd, config, locals);
|
||||
|
||||
expect(create).toHaveBeenCalledWith({ data: { name: 'Editor' } });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('updates an existing record when id is present', async () => {
|
||||
findUnique.mockResolvedValue({ id: 1, name: 'Old Name' });
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('id', '1');
|
||||
fd.set('name', 'New Name');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await upsertRecord(fd, config, locals);
|
||||
|
||||
expect(update).toHaveBeenCalledWith({ where: { id: 1 }, data: { name: 'New Name' } });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('returns error when updating non-existent record', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('id', '999');
|
||||
fd.set('name', 'Ghost');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await upsertRecord(fd, config, locals);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Record not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('crud deleteRecord', () => {
|
||||
beforeEach(() => {
|
||||
deletes.mockReset();
|
||||
});
|
||||
|
||||
it('deletes a record by id', async () => {
|
||||
deletes.mockResolvedValue({ id: 1, name: 'Admin' });
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('id', '1');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await deleteRecord(fd, config, locals);
|
||||
|
||||
expect(deletes).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('returns error when id is missing', async () => {
|
||||
const fd = new FormData();
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await deleteRecord(fd, config, locals);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Missing id' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('P2002 unique constraint handling', () => {
|
||||
beforeEach(() => {
|
||||
create.mockReset();
|
||||
});
|
||||
|
||||
it('returns error with field names when meta.target is populated', async () => {
|
||||
const p2002 = new Error('Unique constraint failed');
|
||||
(p2002 as any).code = 'P2002';
|
||||
(p2002 as any).meta = { target: ['name'], modelName: 'UserRole' };
|
||||
create.mockRejectedValue(p2002);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('name', 'Admin');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await upsertRecord(fd, config, locals);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'A record with this name already exists.',
|
||||
fieldErrors: { name: 'Already taken' }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns generic message when meta.target is empty', async () => {
|
||||
const p2002 = new Error('Unique constraint failed');
|
||||
(p2002 as any).code = 'P2002';
|
||||
(p2002 as any).meta = { target: [], modelName: 'UserRole' };
|
||||
create.mockRejectedValue(p2002);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('name', 'Admin');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await upsertRecord(fd, config, locals);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'A database constraint violation occurred. The record could not be created.'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns generic message when meta.target is missing', async () => {
|
||||
const p2002 = new Error('Unique constraint failed');
|
||||
(p2002 as any).code = 'P2002';
|
||||
(p2002 as any).meta = { modelName: 'UserRole' };
|
||||
create.mockRejectedValue(p2002);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('name', 'Admin');
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await upsertRecord(fd, config, locals);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'A database constraint violation occurred. The record could not be created.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sveltekit-superforms import compatibility', () => {
|
||||
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
|
||||
expect(superValidate).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls superValidate with a Request and validates against a Zod schema', async () => {
|
||||
const { z } = await import('zod');
|
||||
const schema = z.object({ name: z.string().min(1) });
|
||||
const body = new URLSearchParams({ name: 'test' });
|
||||
const request = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
const form = await superValidate(request, zod4(schema));
|
||||
expect(form.valid).toBe(true);
|
||||
expect(form.data).toEqual({ name: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('prisma client version consistency', () => {
|
||||
it('generated client references existing query compiler files', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const runtimeDir = path.resolve('node_modules/@prisma/client/runtime');
|
||||
|
||||
const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts');
|
||||
const content = fs.readFileSync(generatedFile, 'utf-8');
|
||||
const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/);
|
||||
expect(compilerMatch).not.toBeNull();
|
||||
const compilerPath = path.join(runtimeDir, compilerMatch![1]);
|
||||
expect(fs.existsSync(compilerPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { getDelegate, parseId } from './prisma-delegate';
|
||||
import { extractFormData } from './form-data';
|
||||
import type { AdminModelConfig } from './types';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import * as Schemas from './schemas';
|
||||
import type { z } from 'zod';
|
||||
|
||||
const modelSchemas: Record<string, z.ZodObject<any>> = {
|
||||
User: Schemas.User,
|
||||
Profile: Schemas.Profile,
|
||||
UserRole: Schemas.UserRole,
|
||||
Proclamation: Schemas.Proclamation,
|
||||
ProclamationCategory: Schemas.ProclamationCategory,
|
||||
SolfeggioFrequency: Schemas.SolfeggioFrequency,
|
||||
SubscriptionLevel: Schemas.SubscriptionLevel,
|
||||
LicenseKey: Schemas.LicenseKey
|
||||
};
|
||||
|
||||
export function superFormErrorsToFieldErrors(superErrors: Record<string, unknown>): {
|
||||
error: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
} {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
let formError = '';
|
||||
for (const [key, value] of Object.entries(superErrors)) {
|
||||
if (key === '_errors' && Array.isArray(value)) {
|
||||
formError = (value[0] as string) ?? '';
|
||||
} else if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'_errors' in value &&
|
||||
Array.isArray((value as Record<string, unknown>)._errors)
|
||||
) {
|
||||
const errMsg = ((value as Record<string, unknown>)._errors as string[])[0];
|
||||
fieldErrors[key] = errMsg;
|
||||
}
|
||||
}
|
||||
return { error: formError, fieldErrors };
|
||||
}
|
||||
|
||||
export async function loadRecords(locals: App.Locals, config: AdminModelConfig) {
|
||||
const delegate = getDelegate(config);
|
||||
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const where = config.accessFilter?.(viewerRoleId);
|
||||
|
||||
const records = await delegate.findMany({
|
||||
where,
|
||||
include: config.include,
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
|
||||
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
|
||||
|
||||
for (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 {
|
||||
records: records as Record<string, unknown>[],
|
||||
relationOptionsData
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertRecord(
|
||||
requestOrFormData: Request | FormData,
|
||||
config: AdminModelConfig,
|
||||
locals: App.Locals
|
||||
) {
|
||||
const formData =
|
||||
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
||||
|
||||
const schema = modelSchemas[config.modelName];
|
||||
const form = schema ? await superValidate(formData, zod4(schema)) : null;
|
||||
|
||||
if (form && !form.valid) {
|
||||
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { ...form, success: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const data =
|
||||
form?.data ??
|
||||
(extractFormData(formData, config.formFields, config.fieldTypes) as Record<string, unknown>);
|
||||
|
||||
// Strip virtual/UI-only fields from Prisma payload
|
||||
for (const f of config.dataOnlyFields ?? []) {
|
||||
delete data[f];
|
||||
}
|
||||
|
||||
const delegate = getDelegate(config);
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const where = config.accessFilter?.(viewerRoleId);
|
||||
|
||||
if (rawId) {
|
||||
const id = parseId(config, rawId);
|
||||
|
||||
const existing = await delegate.findUnique({
|
||||
where: { id },
|
||||
...((config.include ? { include: config.include } : {}) as object)
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: false, error: 'Record not found' };
|
||||
}
|
||||
|
||||
if (where) {
|
||||
const match = await delegate.findFirst({
|
||||
where: { id, ...where }
|
||||
});
|
||||
if (!match) {
|
||||
return { success: false, error: 'Access denied' };
|
||||
}
|
||||
}
|
||||
|
||||
await delegate.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
try {
|
||||
await delegate.create({ data });
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||
const meta = (e as { meta?: { target?: string[]; modelName?: string } }).meta;
|
||||
const fields = meta?.target ?? [];
|
||||
console.error(
|
||||
`[crud] P2002 on ${meta?.modelName ?? 'unknown'} create. data=`,
|
||||
data,
|
||||
`meta.target=`,
|
||||
meta?.target
|
||||
);
|
||||
if (fields.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'A database constraint violation occurred. The record could not be created.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `A record with this ${fields.join(', ')} already exists.`,
|
||||
fieldErrors: Object.fromEntries(fields.map((f) => [f, 'Already taken']))
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRecord(
|
||||
requestOrFormData: Request | FormData,
|
||||
config: AdminModelConfig,
|
||||
locals: App.Locals
|
||||
) {
|
||||
const delegate = getDelegate(config);
|
||||
const formData =
|
||||
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
if (!rawId) return { success: false, error: 'Missing id' };
|
||||
|
||||
const id = parseId(config, rawId);
|
||||
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const where = config.accessFilter?.(viewerRoleId);
|
||||
if (where) {
|
||||
const match = await delegate.findFirst({ where: { id, ...where } });
|
||||
if (!match) return { success: false, error: 'Access denied' };
|
||||
}
|
||||
|
||||
await delegate.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -31,6 +31,10 @@ export function extractFormData(
|
||||
data[field] = val === '' ? null : new Date(val);
|
||||
break;
|
||||
}
|
||||
case 'Tiptap': {
|
||||
data[field] = raw?.toString() ?? '';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
data[field] = raw?.toString() ?? '';
|
||||
break;
|
||||
|
||||
@@ -7,6 +7,7 @@ const models: AdminModelConfig[] = [
|
||||
modelName: 'User',
|
||||
displayName: 'Users',
|
||||
displayNameSingular: 'User',
|
||||
listOnly: true,
|
||||
idType: 'String',
|
||||
include: { role: true, profile: true, licenseKeys: true },
|
||||
listFields: ['name', 'email', 'emailVerified', 'role.name', 'createdAt'],
|
||||
@@ -72,7 +73,7 @@ const models: AdminModelConfig[] = [
|
||||
relationKey: 'addresses',
|
||||
label: 'Addresses',
|
||||
type: 'has-many',
|
||||
displayFields: ['street', 'city', 'state', 'zip']
|
||||
displayFields: ['type', 'street', 'city', 'state', 'postalCode', 'country', 'isDefault']
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -96,17 +97,31 @@ const models: AdminModelConfig[] = [
|
||||
displayName: 'Proclamations',
|
||||
displayNameSingular: 'Proclamation',
|
||||
idType: 'String',
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
listFields: ['name', 'category.displayName', 'solfeggioFrequency.name', 'price'],
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
},
|
||||
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
|
||||
searchFields: ['name', 'content'],
|
||||
formFields: ['name', 'solfeggioFrequencyId', 'categoryId', 'price', 'content'],
|
||||
formFields: [
|
||||
'name',
|
||||
'solfeggioFrequencyId',
|
||||
'parentCategoryId',
|
||||
'categoryId',
|
||||
'subscriptionLevelId',
|
||||
'content'
|
||||
],
|
||||
readonlyFields: ['id'],
|
||||
dataOnlyFields: ['parentCategoryId'],
|
||||
cascadeFields: { categoryId: { parentField: 'parentCategoryId' } },
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
content: 'Tiptap',
|
||||
solfeggioFrequencyId: 'Int',
|
||||
parentCategoryId: 'Int',
|
||||
categoryId: 'Int',
|
||||
price: 'Float'
|
||||
subscriptionLevelId: 'Int'
|
||||
},
|
||||
relationOptions: {
|
||||
solfeggioFrequencyId: {
|
||||
@@ -114,10 +129,20 @@ const models: AdminModelConfig[] = [
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
},
|
||||
parentCategoryId: {
|
||||
model: 'ProclamationCategory',
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
},
|
||||
categoryId: {
|
||||
model: 'ProclamationCategory',
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
},
|
||||
subscriptionLevelId: {
|
||||
model: 'SubscriptionLevel',
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -134,14 +159,39 @@ const models: AdminModelConfig[] = [
|
||||
readonlyFields: ['id'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
value: 'Float'
|
||||
value: 'Int'
|
||||
},
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'proclamations',
|
||||
label: 'Proclamations',
|
||||
type: 'has-many',
|
||||
displayFields: ['name', 'price'],
|
||||
displayFields: ['name'],
|
||||
editSlug: 'proclamations'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'subscription-levels',
|
||||
modelName: 'SubscriptionLevel',
|
||||
displayName: 'Subscription Levels',
|
||||
displayNameSingular: 'Subscription Level',
|
||||
idType: 'Int',
|
||||
include: { proclamations: true },
|
||||
listFields: ['id', 'name', 'description'],
|
||||
searchFields: ['name', 'description'],
|
||||
formFields: ['name', 'description'],
|
||||
readonlyFields: ['id'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
description: 'String'
|
||||
},
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'proclamations',
|
||||
label: 'Proclamations',
|
||||
type: 'has-many',
|
||||
displayFields: ['name'],
|
||||
editSlug: 'proclamations'
|
||||
}
|
||||
]
|
||||
@@ -152,14 +202,15 @@ const models: AdminModelConfig[] = [
|
||||
displayName: 'Proclamation Categories',
|
||||
displayNameSingular: 'Proclamation Category',
|
||||
idType: 'Int',
|
||||
include: { proclamations: true, children: true, parent: true },
|
||||
listFields: ['displayName', 'parent.name'],
|
||||
include: { proclamations: true, children: true },
|
||||
listFields: ['name'],
|
||||
searchFields: ['name'],
|
||||
formFields: ['name', 'parentId'],
|
||||
readonlyFields: ['id'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
parentId: 'Int'
|
||||
parentId: 'Int',
|
||||
sortOrder: 'Int'
|
||||
},
|
||||
relationOptions: {
|
||||
parentId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' }
|
||||
@@ -169,7 +220,7 @@ const models: AdminModelConfig[] = [
|
||||
relationKey: 'proclamations',
|
||||
label: 'Proclamations',
|
||||
type: 'has-many',
|
||||
displayFields: ['name', 'price'],
|
||||
displayFields: ['name'],
|
||||
editSlug: 'proclamations'
|
||||
},
|
||||
{
|
||||
@@ -188,7 +239,6 @@ const models: AdminModelConfig[] = [
|
||||
displayNameSingular: 'License Key',
|
||||
idType: 'String',
|
||||
include: { user: true },
|
||||
listOnly: true,
|
||||
listFields: ['key', 'active', 'level', 'user.name'],
|
||||
searchFields: ['key', 'level'],
|
||||
formFields: ['active', 'level', 'userId'],
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const email = z.string().email('Invalid email address');
|
||||
const phone = z
|
||||
.string()
|
||||
.min(10, 'Phone number must be at least 10 digits')
|
||||
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
|
||||
const name = z.string().min(1, 'Required').max(100, 'Too long');
|
||||
|
||||
export const UserRole = z.object({
|
||||
name: z.string().min(1, 'Required')
|
||||
});
|
||||
|
||||
export const User = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
email,
|
||||
roleId: z.coerce.number().int().min(1)
|
||||
});
|
||||
|
||||
export const Profile = z.object({
|
||||
userId: z.string().min(1, 'Required'),
|
||||
firstName: name,
|
||||
lastName: name,
|
||||
phoneNumber: phone
|
||||
});
|
||||
|
||||
export const Address = z.object({
|
||||
profileId: z.string().min(1),
|
||||
street: z.string().min(1, 'Street is required'),
|
||||
city: z.string().min(1, 'City is required'),
|
||||
state: z.string().min(1, 'State is required'),
|
||||
postalCode: z.string().min(1, 'Postal code is required'),
|
||||
country: z.string().min(1, 'Country is required'),
|
||||
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
|
||||
isDefaultMailing: z.coerce.boolean().default(false),
|
||||
isDefaultBilling: z.coerce.boolean().default(false),
|
||||
isDefaultShipping: z.coerce.boolean().default(false)
|
||||
});
|
||||
|
||||
export const SolfeggioFrequency = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
value: z.coerce.number().int('Must be a whole number')
|
||||
});
|
||||
|
||||
export const SubscriptionLevel = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
description: z.string().optional().default('')
|
||||
});
|
||||
|
||||
export const ProclamationCategory = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
parentId: z.coerce.number().int().optional()
|
||||
});
|
||||
|
||||
export const Proclamation = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
solfeggioFrequencyId: z.coerce.number().int(),
|
||||
parentCategoryId: z.coerce.number().int(),
|
||||
categoryId: z.coerce.number().int().optional(),
|
||||
subscriptionLevelId: z.coerce.number().int(),
|
||||
content: z.string().optional().default('')
|
||||
});
|
||||
|
||||
export const LicenseKey = z.object({
|
||||
active: z.coerce.boolean(),
|
||||
level: z.string().min(1, 'Required'),
|
||||
userId: z.string().min(1, 'Required')
|
||||
});
|
||||
|
||||
export const UserUpdate = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1, 'Required').optional(),
|
||||
email: z.string().email().optional()
|
||||
});
|
||||
@@ -19,6 +19,12 @@ export type RelatedRecordConfig = {
|
||||
editSlug?: string;
|
||||
};
|
||||
|
||||
export type ActionResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AdminModelConfig = {
|
||||
slug: string;
|
||||
modelName: string;
|
||||
@@ -35,4 +41,7 @@ export type AdminModelConfig = {
|
||||
relationOptions?: Record<string, RelationOptionConfig>;
|
||||
relatedRecords?: RelatedRecordConfig[];
|
||||
accessFilter?: (viewerRoleId: number) => Record<string, unknown> | undefined;
|
||||
/** Fields that should be excluded from Prisma create/update (virtual/UI-only) */
|
||||
dataOnlyFields?: string[];
|
||||
cascadeFields?: Record<string, { parentField: string }>;
|
||||
};
|
||||
|
||||
@@ -23,26 +23,30 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-8 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Account</h1>
|
||||
<div class="flex h-full min-h-0 flex-col gap-8 p-6 dark:text-gray-100">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Account</h1>
|
||||
|
||||
<!-- Email (read-only) -->
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">Email</p>
|
||||
<p class="mt-1 text-gray-900">{data.user.email}</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Email</p>
|
||||
<p class="mt-1 text-gray-900 dark:text-gray-100">{data.user.email}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Form -->
|
||||
<section>
|
||||
<h2 class="mb-4 text-lg font-medium text-gray-900">Profile</h2>
|
||||
<h2 class="mb-4 text-lg font-medium text-gray-900 dark:text-gray-100">Profile</h2>
|
||||
|
||||
{#if form?.action === 'updateProfile' && form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
Profile updated successfully.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.action === 'updateProfile' && form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -50,7 +54,7 @@
|
||||
<form method="POST" action="?/updateProfile" class="flex max-w-lg flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-sm font-medium text-gray-700">
|
||||
<label for="firstName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
@@ -59,11 +63,11 @@
|
||||
name="firstName"
|
||||
value={data.user.profile?.firstName ?? ''}
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-sm font-medium text-gray-700">
|
||||
<label for="lastName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
@@ -72,12 +76,12 @@
|
||||
name="lastName"
|
||||
value={data.user.profile?.lastName ?? ''}
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">
|
||||
<label for="phoneNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
@@ -87,13 +91,13 @@
|
||||
bind:value={phoneNumber}
|
||||
oninput={handlePhoneInput}
|
||||
required
|
||||
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
@@ -104,10 +108,10 @@
|
||||
<!-- Addresses -->
|
||||
<section>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium text-gray-900">Addresses</h2>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Addresses</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
onclick={() => (showAddressForm = !showAddressForm)}
|
||||
>
|
||||
{showAddressForm ? 'Cancel' : 'Add Address'}
|
||||
@@ -115,12 +119,16 @@
|
||||
</div>
|
||||
|
||||
{#if form?.action === 'addAddress' && form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
Address added successfully.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.action === 'addAddress' && form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -129,44 +137,53 @@
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addAddress"
|
||||
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4"
|
||||
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label for="street" class="block text-sm font-medium text-gray-700">Street</label>
|
||||
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Street</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="city" class="block text-sm font-medium text-gray-700">City</label>
|
||||
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>City</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
||||
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>State</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="postalCode" class="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
for="postalCode"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
@@ -174,25 +191,28 @@
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="country" class="block text-sm font-medium text-gray-700">Country</label>
|
||||
<label
|
||||
for="country"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
required
|
||||
value="US"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
@@ -204,17 +224,20 @@
|
||||
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.user.profile.addresses as address}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p class="text-sm text-gray-900">{address.street}</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{address.city}, {address.state} {address.postalCode}
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{address.street}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{address.city}, {address.state}
|
||||
{address.postalCode}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">{address.country}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{address.country}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !showAddressForm}
|
||||
<p class="text-sm text-gray-500">No addresses on file.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No addresses on file.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +168,9 @@ describe('(protected)/account page server', () => {
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('addAddress', request, createLocals())
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(invokeAction('addAddress', request, createLocals())).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('(protected)/account page server', () => {
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('addAddress', request, locals)
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(invokeAction('addAddress', request, locals)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 400)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const actions: Actions = {
|
||||
|
||||
if (profile) {
|
||||
console.log(`created profile: ${JSON.stringify(profile)}`);
|
||||
throw redirect(303, '/account');
|
||||
throw redirect(303, '/dashboard');
|
||||
} else {
|
||||
return fail(400);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
There should be a form in the middle
|
||||
rounded corners
|
||||
floats above the screen slightly
|
||||
Profile picture in the center at the top, about 1/3-1/2 the width of the form
|
||||
Their email address, not editable, instead is a regular sized black text (or white in dark mode)
|
||||
Input field for their Phone Number
|
||||
Two inputs, side by side, a little margin between them. First and Last name
|
||||
A submit button
|
||||
Save the form then redirect them to their dashboard
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import Window from '$lib/components/containers/Window.svelte';
|
||||
import { clientFormatPhoneNumber } from '$lib/utils';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
|
||||
function splitName(fullName: string | null | undefined): { first: string; last: string } {
|
||||
if (!fullName) return { first: '', last: '' };
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
return { first: parts[0] ?? '', last: parts.slice(1).join(' ') };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (data.user?.name && !firstName) {
|
||||
const names = splitName(data.user.name);
|
||||
firstName = names.first;
|
||||
lastName = names.last;
|
||||
}
|
||||
});
|
||||
let phoneNumber = $state('');
|
||||
|
||||
function handlePhoneInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
phoneNumber = clientFormatPhoneNumber(input.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Window>
|
||||
<form method="POST" class="border p-6 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100">
|
||||
<p class="mx-auto my-4 text-center italic">Please finish setting up your profile</p>
|
||||
{#if data.user?.image}
|
||||
<img src={data.user?.image} class="mx-auto w-[8rem] rounded-full" alt="Profile" />
|
||||
{:else}
|
||||
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></div>
|
||||
{/if}
|
||||
<p class="mx-auto my-4 text-center">{data.user?.email}</p>
|
||||
<div class="flex flex-col">
|
||||
<input type="text" name="userId" value={data.user?.id} class="hidden" />
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
placeholder="First Name"
|
||||
class="my-2"
|
||||
bind:value={firstName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
placeholder="Last Name"
|
||||
class="my-2"
|
||||
bind:value={lastName}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
name="phoneNumber"
|
||||
placeholder="Phone Number"
|
||||
class="my-2"
|
||||
bind:value={phoneNumber}
|
||||
oninput={handlePhoneInput}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-300 p-2 px-4 dark:bg-gray-700 dark:text-gray-100"
|
||||
>Submit</button
|
||||
>
|
||||
</form>
|
||||
</Window>
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError, isRedirect, isActionFailure, redirect } from '@sveltejs/kit';
|
||||
import { createLocals, mockUser, createFormRequest } from '$lib/test-utils';
|
||||
import { serverFormatPhoneNumber } from '$lib/utils';
|
||||
|
||||
const { create, findUnique } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique
|
||||
},
|
||||
profile: {
|
||||
create
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/utils', () => ({
|
||||
serverFormatPhoneNumber: vi.fn((val: string) => `formatted-${val}`)
|
||||
}));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const invokeDefault = (request: Request) => actions.default({ request } as any);
|
||||
|
||||
describe('account setup page server load', () => {
|
||||
beforeEach(() => {
|
||||
create.mockReset();
|
||||
});
|
||||
|
||||
it('throws 403 when user is not logged in', async () => {
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns user data when logged in', async () => {
|
||||
const user = mockUser({ id: 'user-1', name: 'Jane Doe' });
|
||||
findUnique.mockResolvedValue(user);
|
||||
|
||||
const result = await load({ locals: createLocals(user as any) } as any);
|
||||
expect(result).toEqual({ user });
|
||||
});
|
||||
});
|
||||
|
||||
describe('account setup page server action', () => {
|
||||
beforeEach(() => {
|
||||
create.mockReset();
|
||||
});
|
||||
|
||||
it('redirects to /dashboard on successful profile creation', async () => {
|
||||
create.mockResolvedValue({ id: 'profile-1', userId: 'user-1' });
|
||||
|
||||
const request = createFormRequest({
|
||||
userId: 'user-1',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '555-0100'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e);
|
||||
expect(isRedirect(err)).toBe(true);
|
||||
if (isRedirect(err)) {
|
||||
expect(err.status).toBe(303);
|
||||
expect(err.location).toBe('/dashboard');
|
||||
}
|
||||
expect(serverFormatPhoneNumber).toHaveBeenCalledWith('555-0100');
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: 'formatted-555-0100'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fail(400) when profile.create returns falsy', async () => {
|
||||
create.mockResolvedValue(null);
|
||||
|
||||
const request = createFormRequest({
|
||||
userId: 'user-1',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '555-0100'
|
||||
});
|
||||
|
||||
const result = await invokeDefault(request);
|
||||
expect(isActionFailure(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws 400 error when profile.create throws', async () => {
|
||||
create.mockRejectedValue(new Error('db error'));
|
||||
|
||||
const request = createFormRequest({
|
||||
userId: 'user-1',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '555-0100'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e);
|
||||
expect(isHttpError(err, 400)).toBe(true);
|
||||
if (isHttpError(err)) {
|
||||
expect(err.body.message).toBe('bad request');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws redirect errors from profile.create', async () => {
|
||||
let redirectError: unknown;
|
||||
try {
|
||||
redirect(303, '/dashboard');
|
||||
} catch (e) {
|
||||
redirectError = e;
|
||||
}
|
||||
create.mockRejectedValue(redirectError);
|
||||
|
||||
const request = createFormRequest({
|
||||
userId: 'user-1',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '555-0100'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(invokeDefault(request)).catch((e: unknown) => e);
|
||||
expect(err).toBe(redirectError);
|
||||
});
|
||||
});
|
||||
@@ -4,35 +4,39 @@
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Welcome, {data.firstName}!</h1>
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Welcome, {data.firstName}!
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="/license-keys"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500">Active License Keys</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900">{data.activeKeys}</p>
|
||||
<p class="mt-1 text-sm text-gray-400">{data.totalKeys} total</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active License Keys</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">{data.activeKeys}</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">{data.totalKeys} total</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/proclamations"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500">Proclamations</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900">{data.proclamationCount}</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Browse all</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Proclamations</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{data.proclamationCount}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Browse all</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/account"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500">Account</p>
|
||||
<p class="mt-2 text-lg font-semibold text-gray-900">Manage Profile</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Edit your details</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Account</p>
|
||||
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">Manage Profile</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Edit your details</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,39 +4,47 @@
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">License Keys</h1>
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">License Keys</h1>
|
||||
|
||||
{#if data.licenseKeys.length > 0}
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Key
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Level
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
{#each data.licenseKeys as key}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900">
|
||||
<td
|
||||
class="px-6 py-4 font-mono text-sm whitespace-nowrap text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{key.key}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700">
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300">
|
||||
{key.level}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'}"
|
||||
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold {key.active
|
||||
? 'bg-green-100 text-green-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-gray-700 dark:text-gray-200'}"
|
||||
>
|
||||
{key.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
@@ -48,7 +56,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<p class="text-lg text-gray-500">You don't have any license keys yet.</p>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400">You don't have any license keys yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,11 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
const [proclamations, categories] = await Promise.all([
|
||||
prisma.proclamation.findMany({
|
||||
where,
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
|
||||
|
||||
@@ -34,39 +34,43 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Proclamations</h1>
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Proclamations</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Search</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
bind:value={search}
|
||||
placeholder="Search by name..."
|
||||
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Category</label
|
||||
>
|
||||
<select
|
||||
id="category"
|
||||
bind:value={categoryId}
|
||||
onchange={applyFilters}
|
||||
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
|
||||
>
|
||||
<option value={null}>All Categories</option>
|
||||
{#each data.categories as cat}
|
||||
<option value={cat.id}>{displayCategoryName(cat)}</option>
|
||||
<option value={cat.id}>{displayCategoryName(cat)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
|
||||
onclick={applyFilters}
|
||||
>
|
||||
Search
|
||||
@@ -74,7 +78,7 @@
|
||||
{#if data.search || data.categoryId}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
onclick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
@@ -88,17 +92,19 @@
|
||||
{#each data.proclamations as proclamation}
|
||||
<a
|
||||
href="/proclamations/{proclamation.id}"
|
||||
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900">{proclamation.name}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">{displayCategoryName(proclamation.category)}</p>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">{proclamation.name}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{displayCategoryName(proclamation.category)}
|
||||
</p>
|
||||
<div class="mt-auto flex items-center justify-between pt-4">
|
||||
<span class="text-sm text-purple-600">
|
||||
<span class="text-sm text-purple-600 dark:text-gray-400">
|
||||
{proclamation.solfeggioFrequency.value} Hz —
|
||||
{proclamation.solfeggioFrequency.name}
|
||||
</span>
|
||||
<span class="font-semibold text-gray-900">
|
||||
${proclamation.price.toFixed(2)}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{proclamation.subscriptionLevel?.name ?? 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -106,7 +112,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<p class="text-lg text-gray-500">No proclamations found.</p>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400">No proclamations found.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,8 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,36 +10,44 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6">
|
||||
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6 dark:text-gray-100">
|
||||
<a
|
||||
href="/proclamations"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 hover:underline dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
← Back to Proclamations
|
||||
</a>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">{data.proclamation.name}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">{categoryBreadcrumb}</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.proclamation.name}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{categoryBreadcrumb}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2">
|
||||
<p class="text-xs font-medium text-purple-600">Frequency</p>
|
||||
<p class="text-sm font-semibold text-purple-900">
|
||||
<div
|
||||
class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-xs font-medium text-purple-600 dark:text-gray-400">Frequency</p>
|
||||
<p class="text-sm font-semibold text-purple-900 dark:text-gray-100">
|
||||
{data.proclamation.solfeggioFrequency.value} Hz —
|
||||
{data.proclamation.solfeggioFrequency.name}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<p class="text-xs font-medium text-gray-500">Price</p>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
${data.proclamation.price.toFixed(2)}
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Level</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.proclamation.subscriptionLevel?.name ?? 'Standard'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div
|
||||
class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 dark:prose-invert"
|
||||
>
|
||||
{@html data.proclamation.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,9 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
|
||||
it('throws 404 when proclamation is not found', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
|
||||
const err = await Promise.resolve(
|
||||
load({ params: { id: 'nonexistent' } } as any)
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(load({ params: { id: 'nonexistent' } } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 404)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,8 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
|
||||
where: { id: 'proc-1' },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { load } from './+page.server';
|
||||
const proclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||
const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/admin/proclamations page server load', () => {
|
||||
describe('(protected)/admin/proclamations page server load', () => {
|
||||
beforeEach(() => {
|
||||
proclamationFindMany.mockReset();
|
||||
categoryFindMany.mockReset();
|
||||
@@ -29,10 +29,12 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
|
||||
const url = createUrl('/admin/proclamations');
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.proclamations).toEqual(proclamations);
|
||||
expect(result.categories[0]).toEqual(expect.objectContaining({
|
||||
...categories[0],
|
||||
displayName: categories[0].name
|
||||
}));
|
||||
expect(result.categories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
...categories[0],
|
||||
displayName: categories[0].name
|
||||
})
|
||||
);
|
||||
expect(result.search).toBe('');
|
||||
expect(result.categoryId).toBeNull();
|
||||
});
|
||||
@@ -109,7 +111,11 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,15 +2,20 @@ import type { LayoutServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
if (!locals.user?.id) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: locals.user?.id },
|
||||
where: { id: locals.user.id },
|
||||
select: { id: true, roleId: true, profile: { select: { id: true } } }
|
||||
});
|
||||
if (!user) {
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
if (!user.profile) {
|
||||
if (!user.profile && !url.pathname.startsWith('/account/setup')) {
|
||||
// Skip redirect when already on /account/setup to avoid redirect loop
|
||||
throw redirect(303, '/account/setup');
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<a
|
||||
href={link.href}
|
||||
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
@@ -39,8 +39,8 @@
|
||||
<a
|
||||
href="/admin"
|
||||
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}"
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
|
||||
>
|
||||
Admin Panel
|
||||
</a>
|
||||
@@ -48,15 +48,21 @@
|
||||
{#each adminModels as model}
|
||||
<a
|
||||
href="/admin/{model.slug}"
|
||||
class="rounded px-3 py-1 text-sm transition-colors {isActive(
|
||||
'/admin/' + model.slug
|
||||
)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800'}"
|
||||
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/' + model.slug)
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
|
||||
>
|
||||
{model.displayName}
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/admin/api-keys"
|
||||
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/api-keys')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
|
||||
>
|
||||
API Keys
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { error } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { canAccessAdmin } from '$lib/svelte-admin/roles';
|
||||
|
||||
/**
|
||||
/**
|
||||
* This Layout Server-Side page checks if the user is logged in
|
||||
* and if that user has a sufficient permission level to view any pages
|
||||
* rendering this layout, redirecting them to a 403 error page if not.
|
||||
* rendering this layout, redirecting them to a 403 error page if not.
|
||||
* It also makes the `adminUser` (the currently logged in user) available
|
||||
* to all of those pages via page data.
|
||||
*/
|
||||
@@ -18,7 +18,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
});
|
||||
|
||||
if (!user || !canAccessAdmin(user.roleId)) {
|
||||
console.log(`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`);
|
||||
console.log(
|
||||
`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`
|
||||
);
|
||||
throw error(403, 'not authorized');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys');
|
||||
return { models };
|
||||
};
|
||||
@@ -1 +1,21 @@
|
||||
<h1>Admin Dashboard</h1>
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let models = $derived(data.models);
|
||||
</script>
|
||||
|
||||
<h1 class="p-6 text-xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
|
||||
<div class="grid grid-cols-1 gap-4 px-6 pb-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each models as model (model.slug)}
|
||||
<a
|
||||
href={'/admin/' + model.slug}
|
||||
class="block border border-gray-200 p-4 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<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
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { prisma } from '$lib/db';
|
||||
import { rotateApiKey } from '$lib/server/api-key';
|
||||
import { ROLES } from '$lib/svelte-admin/roles';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
const { adminUser } = await parent();
|
||||
if (adminUser.roleId > ROLES.SUPER) {
|
||||
throw error(403, 'Only Super admins can manage API keys');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const activeKey = await prisma.apiKey.findFirst({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
|
||||
},
|
||||
select: { keyPrefix: true, createdAt: true, expiresAt: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const expiredCount = await prisma.apiKey.count({
|
||||
where: {
|
||||
OR: [{ revokedAt: { not: null, lte: now } }, { expiresAt: { not: null, lte: now } }]
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeKey: activeKey
|
||||
? {
|
||||
prefix: activeKey.keyPrefix,
|
||||
createdAt: activeKey.createdAt.toISOString(),
|
||||
expiresAt: activeKey.expiresAt?.toISOString() ?? null
|
||||
}
|
||||
: null,
|
||||
totalRotated: expiredCount
|
||||
};
|
||||
};
|
||||
|
||||
async function viewerRoleId(locals: App.Locals): Promise<number> {
|
||||
if (!locals.user?.id) return 4;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: locals.user.id },
|
||||
select: { roleId: true }
|
||||
});
|
||||
return user?.roleId ?? 4;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
rotate: async ({ locals }) => {
|
||||
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
|
||||
return { success: false, error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const result = await rotateApiKey(false);
|
||||
return {
|
||||
success: true,
|
||||
newKey: result.plaintext,
|
||||
mode: 'normal' as const,
|
||||
expiresAt: result.previousExpiresAt?.toISOString() ?? null
|
||||
};
|
||||
},
|
||||
rotateEmergency: async ({ locals }) => {
|
||||
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
|
||||
return { success: false, error: 'Not authorized' };
|
||||
}
|
||||
|
||||
const result = await rotateApiKey(true);
|
||||
return { success: true, newKey: result.plaintext, mode: 'emergency' as const };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let newKey = $state('');
|
||||
let mode = $state<'normal' | 'emergency' | null>(null);
|
||||
let expiryDate = $state<string | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
|
||||
function handleRotate(emergency: boolean) {
|
||||
return ({ cancel }: any) => {
|
||||
const msg = emergency
|
||||
? 'EMERGENCY ROTATION: The current key will be revoked IMMEDIATELY. Continue?'
|
||||
: 'Rotate the API key? The current key will remain valid for 48 hours.';
|
||||
if (!confirm(msg)) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
return async ({ result }: any) => {
|
||||
if (result.type === 'success' && result.status === 200) {
|
||||
if (result.data?.newKey) {
|
||||
newKey = result.data.newKey;
|
||||
mode = result.data.mode ?? null;
|
||||
expiryDate = result.data.expiresAt ?? null;
|
||||
}
|
||||
} else {
|
||||
toastMessage = 'Rotation failed';
|
||||
toastType = 'error';
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(newKey);
|
||||
toastMessage = 'Copied to clipboard';
|
||||
toastType = 'success';
|
||||
}
|
||||
|
||||
function dismissKey() {
|
||||
newKey = '';
|
||||
mode = null;
|
||||
expiryDate = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
|
||||
|
||||
{#if newKey}
|
||||
<div
|
||||
class="mb-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||||
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
|
||||
</p>
|
||||
<p class="mb-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
This key will not be shown again. Copy it now.
|
||||
</p>
|
||||
<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}
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="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>
|
||||
{#if mode === 'normal' && expiryDate}
|
||||
<p class="mt-3 text-xs text-amber-700 dark:text-amber-400">
|
||||
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
|
||||
to give in-field devices time to update.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if toastMessage}
|
||||
<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}
|
||||
</div>
|
||||
{/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>
|
||||
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
|
||||
>{data.activeKey.prefix}</code
|
||||
>
|
||||
</div>
|
||||
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
|
||||
{#if data.activeKey.expiresAt}
|
||||
<div>Expires: {new Date(data.activeKey.expiresAt).toLocaleString()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
|
||||
{/if}
|
||||
{#if data.totalRotated > 0}
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
{data.totalRotated} previous key(s) have been rotated.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
|
||||
<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 method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { prisma } from "$lib/db";
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
console.log(locals.user?.name);
|
||||
// Load proclamation categories
|
||||
const categories = await prisma.proclamationCategory.findMany();
|
||||
console.log(categories);
|
||||
return { categories };
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
function getCategoryNameFromId(id: number): string {
|
||||
if (data.categories) {
|
||||
const target = data.categories.find((category) => category.id === id);
|
||||
return target ? target.name : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Display a list of categories -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hidden">ID</th>
|
||||
<th>Name</th>
|
||||
<th>Parent Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if data.categories}
|
||||
{#each data.categories as category, i (category.id)}
|
||||
<tr class="hover:bg-gray-300">
|
||||
<td class="hidden">{i}</td>
|
||||
<td>{category.name}</td>
|
||||
{#if category.parentId}
|
||||
<td>{getCategoryNameFromId(category.parentId)}</td>
|
||||
{:else}
|
||||
<td></td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { loadRecords } from '$lib/svelte-admin/crud';
|
||||
import { prisma } from '$lib/db';
|
||||
import { sendMail } from '$lib/server/resend';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
const config = getModelConfig('license-keys')!;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const records = await loadRecords(locals, config);
|
||||
|
||||
const subscriptionLevels = await prisma.subscriptionLevel.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
return {
|
||||
...records,
|
||||
subscriptionLevels: subscriptionLevels.map((s: { id: number; name: string }) => ({
|
||||
label: s.name,
|
||||
value: s.name
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
createKey: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email')?.toString();
|
||||
const level = formData.get('level')?.toString();
|
||||
|
||||
if (!email) {
|
||||
return { success: false, error: 'Email is required' };
|
||||
}
|
||||
if (!level) {
|
||||
return { success: false, error: 'Subscription level is required' };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return { success: false, error: 'No user found with that email' };
|
||||
}
|
||||
|
||||
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
|
||||
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||
|
||||
await prisma.licenseKey.create({
|
||||
data: {
|
||||
key,
|
||||
active: true,
|
||||
level,
|
||||
userId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Your License Key',
|
||||
data: {
|
||||
firstName: profile?.firstName ?? user.name ?? 'Valued Customer',
|
||||
licenseKey: key
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to send license key email:', e);
|
||||
}
|
||||
|
||||
return { success: true, error: undefined };
|
||||
},
|
||||
replace: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const formData = await request.formData();
|
||||
const keyId = formData.get('id')?.toString();
|
||||
if (!keyId) return { success: false, error: 'Missing key id' };
|
||||
|
||||
const existing = await prisma.licenseKey.findUnique({ where: { id: keyId } });
|
||||
if (!existing) return { success: false, error: 'License key not found' };
|
||||
|
||||
await prisma.licenseKey.delete({ where: { id: keyId } });
|
||||
|
||||
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)}`;
|
||||
|
||||
await prisma.licenseKey.create({
|
||||
data: {
|
||||
key,
|
||||
active: true,
|
||||
level: existing.level,
|
||||
userId: existing.userId
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, error: undefined };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import LicenseKeyForm from './LicenseKeyForm.svelte';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('license-keys')!;
|
||||
|
||||
let createOpen = $state(false);
|
||||
let replaceOpen = $state(false);
|
||||
let replaceRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
|
||||
function openCreate() {
|
||||
createOpen = true;
|
||||
}
|
||||
|
||||
function openReplace(record: Record<string, unknown>) {
|
||||
replaceRecord = record;
|
||||
replaceOpen = true;
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
createOpen = false;
|
||||
}
|
||||
|
||||
function closeReplace() {
|
||||
replaceOpen = false;
|
||||
replaceRecord = null;
|
||||
}
|
||||
|
||||
function replaceEnhance() {
|
||||
return async ({ result, update }: any) => {
|
||||
if (result.type === 'success' && result.status === 200) {
|
||||
closeReplace();
|
||||
toastMessage = 'License key replaced';
|
||||
toastType = 'success';
|
||||
} else {
|
||||
toastMessage = 'Failed to replace license key';
|
||||
toastType = 'error';
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<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>
|
||||
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
copyableFields={['key']}
|
||||
onRowClick={openReplace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
|
||||
<LicenseKeyForm subscriptionLevels={data.subscriptionLevels} onSuccess={closeCreate} />
|
||||
</AdminModal>
|
||||
|
||||
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
|
||||
{#if replaceRecord}
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
|
||||
</p>
|
||||
<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 class="text-gray-500 dark:text-gray-400">
|
||||
Level: <span class="font-medium text-gray-700 dark:text-gray-300"
|
||||
>{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)} />
|
||||
<button
|
||||
type="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>
|
||||
</div>
|
||||
{/if}
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,91 @@
|
||||
<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>
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
|
||||
|
||||
const config = getModelConfig('proclamation-categories')!;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const delegate = getDelegate(config);
|
||||
|
||||
const records = await delegate.findMany({
|
||||
where: { parentId: null },
|
||||
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 {
|
||||
records: records as Record<string, unknown>[],
|
||||
relationOptionsData
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
upsert: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = (formData.get('name')?.toString() ?? '').trim();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const childIds = formData.getAll('childId').map((v) => v.toString());
|
||||
const childNames = formData
|
||||
.getAll('childName')
|
||||
.map((v) => v.toString().trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name) {
|
||||
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
|
||||
}
|
||||
|
||||
const delegate = getDelegate(config);
|
||||
|
||||
try {
|
||||
let categoryId: number;
|
||||
if (rawId) {
|
||||
const id = parseInt(rawId, 10);
|
||||
await delegate.update({
|
||||
where: { id },
|
||||
data: { name }
|
||||
});
|
||||
categoryId = id;
|
||||
} else {
|
||||
const maxSort = await delegate.findFirst({
|
||||
where: { parentId: null },
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
select: { sortOrder: true }
|
||||
});
|
||||
const sortOrder = (maxSort?.sortOrder ?? -1) + 1;
|
||||
const created = await delegate.create({
|
||||
data: { name, sortOrder }
|
||||
});
|
||||
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({
|
||||
data: { name: childName, parentId: categoryId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of existingChildren) {
|
||||
if (!submittedIdSet.has(child.id)) {
|
||||
await delegate.delete({ where: { id: child.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'A sub-category with this name already exists under this parent.'
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
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' };
|
||||
}
|
||||
|
||||
await delegate.deleteMany({ where: { parentId: id } });
|
||||
await delegate.delete({ where: { id } });
|
||||
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import CategoryForm from './CategoryForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('proclamation-categories')!;
|
||||
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as any)) {
|
||||
const result = form as any;
|
||||
if (result.success) {
|
||||
toastMessage = 'Saved successfully';
|
||||
toastType = 'success';
|
||||
closeModal();
|
||||
} else {
|
||||
formError = result.error ?? '';
|
||||
fieldErrors = result.fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
draggable={true}
|
||||
onReorder={handleReorder}
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<CategoryForm
|
||||
record={editingRecord ?? {}}
|
||||
{fieldErrors}
|
||||
{formError}
|
||||
submitLabel={editingRecord ? 'Save' : 'Create'}
|
||||
/>
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,124 @@
|
||||
<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,97 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
|
||||
import { prisma } from '$lib/db';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import * as Schemas from '$lib/svelte-admin/schemas';
|
||||
|
||||
const config = getModelConfig('proclamations')!;
|
||||
|
||||
function viewerRoleId(locals: App.Locals): number {
|
||||
return (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
}
|
||||
|
||||
function isSuper(locals: App.Locals): boolean {
|
||||
return viewerRoleId(locals) <= 1;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const base = await loadRecords(locals, config);
|
||||
|
||||
// Strip content from non-Super users
|
||||
if (!isSuper(locals)) {
|
||||
for (const record of base.records) {
|
||||
delete record.content;
|
||||
}
|
||||
}
|
||||
|
||||
const categories = await prisma.proclamationCategory.findMany({
|
||||
select: { id: true, name: true, parentId: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
return { ...base, categories };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
upsert: async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
if (!isSuper(locals)) {
|
||||
return { success: false, error: 'Only Super admins can edit proclamations' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const form = await superValidate(formData, zod4(Schemas.Proclamation));
|
||||
if (!form.valid) {
|
||||
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { success: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const { parentCategoryId, ...rest } = form.data;
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
...rest,
|
||||
categoryId: rest.categoryId ?? parentCategoryId
|
||||
};
|
||||
|
||||
try {
|
||||
if (rawId) {
|
||||
const existing = await prisma.proclamation.findUnique({
|
||||
where: { id: rawId }
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: false, error: 'Record not found' };
|
||||
}
|
||||
await prisma.proclamation.update({
|
||||
where: { id: rawId },
|
||||
data: data as any
|
||||
});
|
||||
} else {
|
||||
await prisma.proclamation.create({ data: data as any });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'A record with this name already exists.'
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!isSuper(locals)) {
|
||||
return { success: false, error: 'Only Super admins can delete proclamations' };
|
||||
}
|
||||
return deleteRecord(request, config, locals);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import ProclamationForm from './ProclamationForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('proclamations')!;
|
||||
const viewerRoleId = $derived(data.adminUser?.roleId ?? 4);
|
||||
const isSuper = $derived(viewerRoleId <= 1);
|
||||
const canDelete = $derived(viewerRoleId <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as any)) {
|
||||
const result = form as any;
|
||||
if (result.success) {
|
||||
toastMessage = 'Saved successfully';
|
||||
toastType = 'success';
|
||||
closeModal();
|
||||
} else {
|
||||
formError = result.error ?? '';
|
||||
fieldErrors = result.fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={isSuper ? openEdit : undefined}
|
||||
onNew={isSuper ? openCreate : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<ProclamationForm
|
||||
record={editingRecord ?? {}}
|
||||
relationOptionsData={data.relationOptionsData}
|
||||
categories={data.categories}
|
||||
{fieldErrors}
|
||||
{formError}
|
||||
{isSuper}
|
||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||
/>
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,237 @@
|
||||
<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>
|
||||
@@ -0,0 +1,152 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { createLocals, mockUser, createFormRequest } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamation: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
},
|
||||
proclamationCategory: {
|
||||
findMany: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/svelte-admin/crud', () => ({
|
||||
loadRecords: vi.fn(),
|
||||
deleteRecord: vi.fn(),
|
||||
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const mockLoadRecords = loadRecords as ReturnType<typeof vi.fn>;
|
||||
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
|
||||
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
|
||||
const mockCatFindMany = prisma.proclamationCategory.findMany 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 mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('proclamations admin page server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
const recordsWithContent = [
|
||||
{ id: '1', name: 'A', content: '<p>secret</p>' },
|
||||
{ id: '2', name: 'B', content: '<p>secret2</p>' }
|
||||
];
|
||||
const relationOptionsData = {};
|
||||
const categories = [{ id: 1, name: 'Healing', parentId: null }];
|
||||
|
||||
it('strips content for non-Super users', async () => {
|
||||
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
|
||||
mockCatFindMany.mockResolvedValue(categories);
|
||||
|
||||
const locals = createLocals(mockUser({ roleId: 4 }) as any);
|
||||
const result = await load({ locals } as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
records: [
|
||||
{ id: '1', name: 'A' },
|
||||
{ id: '2', name: 'B' }
|
||||
],
|
||||
relationOptionsData,
|
||||
categories
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps content for Super users', async () => {
|
||||
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
|
||||
mockCatFindMany.mockResolvedValue(categories);
|
||||
|
||||
const locals = createLocals(mockUser({ roleId: 1 }) as any);
|
||||
const result = await load({ locals } as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
records: recordsWithContent,
|
||||
relationOptionsData,
|
||||
categories
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert action', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
const locals = createLocals();
|
||||
const req = createFormRequest({ name: 'test' });
|
||||
|
||||
const result = await actions.upsert({ request: req, locals } as any);
|
||||
expect(result).toEqual({ success: false, error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('rejects non-Super users', async () => {
|
||||
const locals = createLocals(mockUser({ roleId: 4 }) as any);
|
||||
const req = createFormRequest({ name: 'test' });
|
||||
|
||||
const result = await actions.upsert({ request: req, locals } as any);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Only Super admins can edit proclamations'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('rejects non-Super users', async () => {
|
||||
const locals = createLocals(mockUser({ roleId: 4 }) as any);
|
||||
const req = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ id: '1' })
|
||||
});
|
||||
|
||||
const result = await actions.delete({ request: req, locals } as any);
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Only Super admins can delete proclamations'
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates to deleteRecord for Super users', async () => {
|
||||
mockDeleteRecord.mockResolvedValue({ success: true });
|
||||
|
||||
const locals = createLocals(mockUser({ roleId: 1 }) as any);
|
||||
const req = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ id: '1' })
|
||||
});
|
||||
|
||||
const result = await actions.delete({ request: req, locals } as any);
|
||||
expect(mockDeleteRecord).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import {
|
||||
loadRecords,
|
||||
upsertRecord,
|
||||
deleteRecord,
|
||||
superFormErrorsToFieldErrors
|
||||
} from '$lib/svelte-admin/crud';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import * as Schemas from '$lib/svelte-admin/schemas';
|
||||
|
||||
const config = getModelConfig('profiles')!;
|
||||
|
||||
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);
|
||||
},
|
||||
upsertAddress: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const form = await superValidate(request, zod4(Schemas.Address));
|
||||
if (!form.valid) {
|
||||
const { error: addrError, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { success: false, addressError: addrError, addressFieldErrors: fieldErrors };
|
||||
}
|
||||
|
||||
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
|
||||
form.data;
|
||||
|
||||
if (!profileId) {
|
||||
return { success: false, addressError: 'Missing profile ID' };
|
||||
}
|
||||
|
||||
// Unique-default enforcement: clear defaults of same type before setting
|
||||
if (isDefaultMailing) {
|
||||
await prisma.address.updateMany({
|
||||
where: { profileId, isDefaultMailing: true },
|
||||
data: { isDefaultMailing: false }
|
||||
});
|
||||
}
|
||||
if (isDefaultBilling) {
|
||||
await prisma.address.updateMany({
|
||||
where: { profileId, isDefaultBilling: true },
|
||||
data: { isDefaultBilling: false }
|
||||
});
|
||||
}
|
||||
if (isDefaultShipping) {
|
||||
await prisma.address.updateMany({
|
||||
where: { profileId, isDefaultShipping: true },
|
||||
data: { isDefaultShipping: false }
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...addressData,
|
||||
profileId,
|
||||
isDefaultMailing,
|
||||
isDefaultBilling,
|
||||
isDefaultShipping
|
||||
};
|
||||
|
||||
const created = await prisma.address.create({ data: payload });
|
||||
return { success: true, address: created };
|
||||
},
|
||||
updateUser: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const form = await superValidate(request, zod4(Schemas.UserUpdate));
|
||||
if (!form.valid) {
|
||||
const { error: userErr, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { success: false, userError: userErr, userFieldErrors: fieldErrors };
|
||||
}
|
||||
|
||||
const { id, email, name } = form.data;
|
||||
if (!id) {
|
||||
return { success: false, userError: 'Missing user ID' };
|
||||
}
|
||||
|
||||
const data: Record<string, string> = {};
|
||||
if (email) data.email = email;
|
||||
if (name) data.name = name;
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { success: false, userError: 'No fields to update' };
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id }, data });
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,487 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('profiles')!;
|
||||
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
let addressFormOpen = $state(false);
|
||||
let userFormOpen = $state(false);
|
||||
let addressError = $state('');
|
||||
let userError = $state('');
|
||||
|
||||
let addressFields = $state<Record<string, string>>({
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
isDefaultMailing: 'false',
|
||||
isDefaultBilling: 'false',
|
||||
isDefaultShipping: 'false'
|
||||
});
|
||||
|
||||
let userEmail = $state('');
|
||||
let userName = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form) {
|
||||
if ((form as any).addressError) {
|
||||
addressError = (form as any).addressError;
|
||||
} else if ((form as any).userError) {
|
||||
userError = (form as any).userError;
|
||||
} else if (form.success) {
|
||||
toastType = 'success';
|
||||
if ((form as any).address) {
|
||||
toastMessage = 'Address saved';
|
||||
const addr = (form as any).address as Record<string, unknown>;
|
||||
if (editingRecord) {
|
||||
const addrs = (editingRecord.addresses as Record<string, unknown>[]) ?? [];
|
||||
editingRecord = {
|
||||
...editingRecord,
|
||||
addresses: [...addrs, addr]
|
||||
};
|
||||
}
|
||||
addressFormOpen = false;
|
||||
addressError = '';
|
||||
} else {
|
||||
toastMessage = 'Saved successfully';
|
||||
closeModal();
|
||||
}
|
||||
} else if (!(form as any).addressError && !(form as any).userError) {
|
||||
formError = (form as ActionResult).error ?? '';
|
||||
fieldErrors = (form as ActionResult).fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
addressFormOpen = false;
|
||||
userFormOpen = false;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
addressFormOpen = false;
|
||||
userFormOpen = false;
|
||||
addressError = '';
|
||||
userError = '';
|
||||
userEmail = ((record.user as Record<string, unknown>)?.email as string) ?? '';
|
||||
userName = ((record.user as Record<string, unknown>)?.name as string) ?? '';
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
addressFormOpen = false;
|
||||
userFormOpen = false;
|
||||
addressError = '';
|
||||
userError = '';
|
||||
}
|
||||
|
||||
function toggleAddressForm() {
|
||||
addressFormOpen = !addressFormOpen;
|
||||
addressError = '';
|
||||
}
|
||||
|
||||
function toggleUserForm() {
|
||||
userFormOpen = !userFormOpen;
|
||||
userError = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<form method="POST" action="?/upsert" use:enhance>
|
||||
{#if editingRecord?.id}
|
||||
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||
{/if}
|
||||
{#if formError}
|
||||
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="field-userId"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
User
|
||||
</label>
|
||||
<select
|
||||
id="field-userId"
|
||||
name="userId"
|
||||
value={String(editingRecord?.userId ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">-- Select User --</option>
|
||||
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if fieldErrors.userId}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="field-firstName"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="field-firstName"
|
||||
name="firstName"
|
||||
value={String(editingRecord?.firstName ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{#if fieldErrors.firstName}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="field-lastName"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="field-lastName"
|
||||
name="lastName"
|
||||
value={String(editingRecord?.lastName ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{#if fieldErrors.lastName}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="field-phoneNumber"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="field-phoneNumber"
|
||||
name="phoneNumber"
|
||||
value={String(editingRecord?.phoneNumber ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{#if fieldErrors.phoneNumber}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{editingRecord ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if editingRecord}
|
||||
{@const user = editingRecord.user as Record<string, unknown> | undefined}
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleUserForm}
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{userFormOpen ? 'Cancel' : 'Edit User'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
|
||||
</p>
|
||||
|
||||
{#if userFormOpen}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateUser"
|
||||
use:enhance
|
||||
class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<input type="hidden" name="id" value={String(user?.id ?? '')} />
|
||||
{#if userError}
|
||||
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
|
||||
{userError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label
|
||||
for="user-name"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user-name"
|
||||
name="name"
|
||||
value={userName}
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="user-email"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="user-email"
|
||||
name="email"
|
||||
value={userEmail}
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Save User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingRecord}
|
||||
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Addresses</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleAddressForm}
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{addressFormOpen ? 'Cancel' : 'Add Address +'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addressFormOpen}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/upsertAddress"
|
||||
use:enhance
|
||||
class="mb-3 rounded border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<input type="hidden" name="profileId" value={String(editingRecord.id)} />
|
||||
{#if addressError}
|
||||
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
|
||||
{addressError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label
|
||||
for="addr-street"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Street
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-street"
|
||||
name="street"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-city"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-city"
|
||||
name="city"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-state"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-state"
|
||||
name="state"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-postal"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-postal"
|
||||
name="postalCode"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-country"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-country"
|
||||
name="country"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" name="isDefaultMailing" class="h-3.5 w-3.5" />
|
||||
Default Mailing
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" name="isDefaultBilling" class="h-3.5 w-3.5" />
|
||||
Default Billing
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" name="isDefaultShipping" class="h-3.5 w-3.5" />
|
||||
Default Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if addresses.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th
|
||||
>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">City</th>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">State</th>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Zip</th>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase"
|
||||
>Country</th
|
||||
>
|
||||
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">M</th>
|
||||
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">B</th>
|
||||
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">S</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each addresses as addr}
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="px-2 py-1">{addr.street as string}</td>
|
||||
<td class="px-2 py-1">{addr.city as string}</td>
|
||||
<td class="px-2 py-1">{addr.state as string}</td>
|
||||
<td class="px-2 py-1">{addr.postalCode as string}</td>
|
||||
<td class="px-2 py-1">{addr.country as string}</td>
|
||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultMailing ? '✓' : ''}</td>
|
||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultBilling ? '✓' : ''}</td>
|
||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultShipping ? '✓' : ''}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">No addresses on file.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
|
||||
|
||||
const config = getModelConfig('solfeggio-frequencies')!;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import FreqForm from './FreqForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('solfeggio-frequencies')!;
|
||||
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as any)) {
|
||||
const result = form as any;
|
||||
if (result.success) {
|
||||
toastMessage = 'Saved successfully';
|
||||
toastType = 'success';
|
||||
closeModal();
|
||||
} else {
|
||||
formError = result.error ?? '';
|
||||
fieldErrors = result.fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<FreqForm
|
||||
record={editingRecord ?? {}}
|
||||
{fieldErrors}
|
||||
{formError}
|
||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||
/>
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,96 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import LevelForm from './LevelForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('subscription-levels')!;
|
||||
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as any)) {
|
||||
const result = form as any;
|
||||
if (result.success) {
|
||||
toastMessage = 'Saved successfully';
|
||||
toastType = 'success';
|
||||
closeModal();
|
||||
} else {
|
||||
formError = result.error ?? '';
|
||||
fieldErrors = result.fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<LevelForm
|
||||
record={editingRecord ?? {}}
|
||||
{fieldErrors}
|
||||
{formError}
|
||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||
/>
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,95 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
|
||||
|
||||
const config = getModelConfig('user-roles')!;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import UserRoleForm from './UserRoleForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('user-roles')!;
|
||||
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as any)) {
|
||||
const result = form as any;
|
||||
if (result.success) {
|
||||
toastMessage = 'Saved successfully';
|
||||
toastType = 'success';
|
||||
closeModal();
|
||||
} else {
|
||||
formError = result.error ?? '';
|
||||
fieldErrors = result.fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<UserRoleForm
|
||||
record={editingRecord ?? {}}
|
||||
{fieldErrors}
|
||||
{formError}
|
||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||
/>
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
|
||||
|
||||
const config = getModelConfig('users')!;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return loadRecords(locals, config);
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
upsert: async ({ request, locals }) => {
|
||||
return upsertRecord(request, config, locals);
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
return deleteRecord(request, config, locals);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import UserForm from './UserForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('users')!;
|
||||
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as any)) {
|
||||
const result = form as any;
|
||||
if (result.success) {
|
||||
toastMessage = 'Saved successfully';
|
||||
toastType = 'success';
|
||||
closeModal();
|
||||
} else {
|
||||
formError = result.error ?? '';
|
||||
fieldErrors = result.fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={openEdit}
|
||||
onNew={config.listOnly ? undefined : openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<UserForm
|
||||
record={editingRecord ?? {}}
|
||||
relationOptionsData={data.relationOptionsData}
|
||||
{fieldErrors}
|
||||
{formError}
|
||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||
/>
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let {
|
||||
record = {} as Record<string, unknown>,
|
||||
fieldErrors = {} as Record<string, string>,
|
||||
formError = '',
|
||||
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
|
||||
action = '?/upsert',
|
||||
submitLabel = 'Save'
|
||||
}: {
|
||||
record?: Record<string, unknown>;
|
||||
fieldErrors?: Record<string, string>;
|
||||
formError?: string;
|
||||
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
|
||||
action?: string;
|
||||
submitLabel?: string;
|
||||
} = $props();
|
||||
|
||||
let roleId = $state(String(record.roleId ?? ''));
|
||||
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-roleId"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
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>
|
||||
{#each relationOptionsData.roleId ?? [] as opt (String(opt.value))}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if fieldErrors.roleId}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.roleId}</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>
|
||||
@@ -25,7 +25,9 @@ describe('(protected) layout server load', () => {
|
||||
it('throws 403 when locals.user is undefined', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -41,7 +43,8 @@ describe('(protected) layout server load', () => {
|
||||
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null });
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
|
||||
const url = new URL('http://localhost/dashboard');
|
||||
const err = await Promise.resolve(load({ locals, url } as any)).catch((e: unknown) => e);
|
||||
expect(isRedirect(err)).toBe(true);
|
||||
if (isRedirect(err)) {
|
||||
expect(err.status).toBe(303);
|
||||
@@ -49,6 +52,15 @@ describe('(protected) layout server load', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('skips redirect when already on /account/setup', async () => {
|
||||
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profile: null });
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const url = new URL('http://localhost/account/setup');
|
||||
const result = await load({ locals, url } as any);
|
||||
expect(result).toEqual({ userRoleId: null });
|
||||
});
|
||||
|
||||
it('returns userRoleId when user has a profile', async () => {
|
||||
findUnique.mockResolvedValue(mockUserWithProfile({ roleId: 2 }));
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
<Window>
|
||||
{#if $page.status === 403}
|
||||
<h1>Not Authorized</h1>
|
||||
<p>
|
||||
You don't have permission to view this page. Click <a
|
||||
<h1 class="dark:text-gray-100">Not Authorized</h1>
|
||||
<p class="dark:text-gray-300">
|
||||
You don't have permission to view this page. <a
|
||||
href="/"
|
||||
class="underlined text-blue-600">here</a
|
||||
> to return home.
|
||||
class="dark:text-gray-400 dark:hover:text-gray-200">Go home</a
|
||||
>.
|
||||
</p>
|
||||
{/if}
|
||||
</Window>
|
||||
|
||||
@@ -1,24 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { canAccessAdmin } from '$lib/svelte-admin/roles';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import TopBar from '$lib/components/ui/topBar/TopBar.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
const session = authClient.useSession();
|
||||
let isAdmin = $derived(canAccessAdmin(data.userRoleId));
|
||||
|
||||
let dark = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
dark = document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
dark = !dark;
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<title>Rhema Wave Technologies</title>
|
||||
<meta name="description" content="Rhema Wave Technologies User Portal — manage proclamations, audio content, and solfeggio frequencies." />
|
||||
<meta name="theme-color" content="#7B2D8E" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Rhema Wave Technologies User Portal — manage proclamations, audio content, and solfeggio frequencies."
|
||||
/>
|
||||
<meta name="theme-color" content="#6b7280" />
|
||||
<meta property="og:title" content="Rhema Wave Technologies" />
|
||||
<meta property="og:description" content="Manage proclamations, audio content, and solfeggio frequencies." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Manage proclamations, audio content, and solfeggio frequencies."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Rhema Wave Technologies" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
</svelte:head>
|
||||
|
||||
<TopBar userRoleId={data.userRoleId} />
|
||||
<div class="flex min-h-0 flex-1 px-8">
|
||||
<nav
|
||||
class="flex w-full items-center justify-between border-b border-gray-200 px-8 py-6 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
|
||||
>
|
||||
<span><a href="/" class="dark:text-gray-100">Rhema Wave Technologies</a></span>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="border border-gray-300 px-3 py-1 text-sm dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{dark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
{#if $session.data}
|
||||
<a href="/dashboard" class="dark:text-gray-300 dark:hover:text-gray-100">Dashboard</a>
|
||||
<button
|
||||
class="dark:text-gray-300 dark:hover:text-gray-100"
|
||||
onclick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = '/';
|
||||
}}>Log Out</button
|
||||
>
|
||||
{:else}
|
||||
<span class="dark:text-gray-300"
|
||||
><a href="/login" class="dark:text-gray-300 dark:hover:text-gray-100">Log In</a> |
|
||||
<a href="/register" class="dark:text-gray-300 dark:hover:text-gray-100">Register</a></span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="flex min-h-0 flex-1 px-8 dark:bg-gray-950">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<script lang="ts">
|
||||
import Center from '$lib/components/containers/Center.svelte';
|
||||
</script>
|
||||
|
||||
<Center>
|
||||
<h1>Hello</h1>
|
||||
</Center>
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
<h1 class="dark:text-gray-100">Hello</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
|
||||
<!--
|
||||
There should be a form in the middle
|
||||
rounded corners
|
||||
floats above the screen slightly
|
||||
Profile picture in the center at the top, about 1/3-1/2 the width of the form
|
||||
Their email address, not editable, instead is a regular sized black text (or white in dark mode)
|
||||
Input field for their Phone Number
|
||||
Two inputs, side by side, a little margin between them. First and Last name
|
||||
A submit button
|
||||
Save the form then redirect them to their dashboard
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import Window from '$lib/components/containers/Window.svelte';
|
||||
import { clientFormatPhoneNumber } from '$lib/utils';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
$inspect(data);
|
||||
|
||||
let phoneNumber = $state('');
|
||||
|
||||
function handlePhoneInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
phoneNumber = clientFormatPhoneNumber(input.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Window>
|
||||
<form method="POST" class="border shadow-lg p-6 rounded-xl">
|
||||
<p class="mx-auto text-center my-4 italic">Please finish setting up your profile</p>
|
||||
{#if data.user?.image}
|
||||
<img src={data.user?.image} class="rounded-full w-[8rem] mx-auto" alt="Profile" />
|
||||
{:else}
|
||||
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></div>
|
||||
{/if}
|
||||
<p class="mx-auto text-center my-4">{data.user?.email}</p>
|
||||
<div class="flex flex-col">
|
||||
<input type="text" name="userId" value={data.user?.id} class="hidden"/>
|
||||
<input type="text" name="firstName" placeholder="First Name" class="my-2" />
|
||||
<input type="text" name="lastName" placeholder="Last Name" class="my-2" />
|
||||
<input
|
||||
type="tel"
|
||||
name="phoneNumber"
|
||||
placeholder="Phone Number"
|
||||
class="my-2"
|
||||
bind:value={phoneNumber}
|
||||
oninput={handlePhoneInput}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="p-2 px-4 bg-blue-300 rounded-xl">Submit</button>
|
||||
</form>
|
||||
</Window>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user