Files
portal/AGENTS.md
T

17 KiB

portal.Rhemawavetech.com

SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.

Quick start

npm install          # engine-strict=true in .npmrc
npm run prepare      # svelte-kit sync (generate types)
npm run dev          # vite dev on :5173

Requires .env with at least DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL.

Commands

npm run ... What it does
check svelte-kit sync && svelte-check (typecheck)
lint prettier --check . && eslint . (order matters)
format prettier --write .
test vitest --run (single run)
test:unit vitest (watch mode)
migrate -- --name <name> Create a Prisma migration
deploy Apply pending migrations
build Production build (adapter-node)

Testing quirks

  • Two Vitest projects: client (Playwright/Chromium headless, src/**/*.svelte.{test,spec}.{js,ts}) and server (node env, everything else — src/**/*.{test,spec}.{js,ts} excluding .svelte.*). Tests co-located beside source files.
  • expect: { requireAssertions: true } — every test must assert something. No assertions = test fails.
  • All route tests mock $lib/db (Prisma client) and $lib/auth-client/$lib/auth via vi.mock(). Stub factories in src/lib/test-utils.ts.
  • No .env.test — mocking $lib/db makes real DB unnecessary.
  • Mock $app/forms for any component using use:enhance — provide enhance: () => () => {} (action → destroy fn).
  • Mock TipTap for client tests rendering ProclamationForm — the Svelte 5 component mock pattern is a function ($anchor: Element) => { …; $anchor.replaceWith(div); }.

Architecture notes

  • Route groups: (protected)/ requires auth + profile; (user)/ is user-facing; admin/ requires roleId <= 3 (STAFF+).
  • Admin forms: Each route has its own form component (e.g. ProclamationForm.svelte, CategoryForm.svelte). The generic AdminForm.svelte / AdminPage.svelte were deleted. Registry config (registry.ts) still provides metadata, list fields, search fields, relation options, etc., but forms are hand-written per route.
  • Prisma client generated to src/lib/generated/prisma/ (not node_modules/.prisma/client). Lazy singleton via Proxy in src/lib/db.ts.
  • Better-Auth server instance in src/lib/auth.ts, browser client in src/lib/auth-client.ts. Session fetched in src/hooks.server.ts.
  • Roles: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4). canAccessAdmin checks roleId <= 3.
  • Payments are stubbed (src/lib/payments.ts — setTimeout + console.log).
  • API keys: SHA-256 hashed before storage (ApiKey model); only prefix (11 chars) stored for identification. /api/v1/* requires Authorization: Bearer <key>, except /api/v1/automations/ (GHL webhook, no API key needed). Rotation: normal (48h grace via expiresAt) or emergency (immediate via revokedAt). Super-only admin page at /admin/api-keys.

Formatting conventions

  • Tabs, single quotes, no trailing commas, 100 print width (enforced by Prettier).
  • Svelte 5 runes only ($state, $derived, $effect, $props). No legacy stores (writable, derived, etc.).

Svelte 5 Runes Gotchas & Patterns

$state initialization vs $effect sync

State variables declared with $state('') are set once on component mount. To keep them in sync with a changing $props() value, use a separate $effect:

<script lang="ts">
	let { record = {} as Record<string, unknown> } = $props();

	let name = $state('');
	let freq = $state('');

	// Sync local state when prop changes
	$effect(() => {
		name = String(record.name ?? '');
		freq = String(record.solfeggioFrequencyId ?? '');
	});
</script>

This is the only correct pattern for form fields that edit an existing record. Do NOT initialize $state directly from the prop — that only captures the initial value and won't re-sync when the parent changes the record (e.g. switching from one row to another in a modal).

$effect ordering matters

Effects run in declaration order. If you have multiple effects, the first one's writes are applied before the second one runs. This matters for the dirty flag:

let dirty = $state(false);

// Runs first — resets dirty on mount / record change
$effect(() => {
	dirty = false;
});

// Runs second — syncs field values
$effect(() => {
	name = String(record.name ?? '');
	// …
});

Both effects run synchronously during mount, so by the time the browser paints, all state is consistent.

bind:value on <select> is broken with $effect-driven state

DO NOT USE bind:value={stateVar} on <select> elements when the state is set from a $effect. Svelte 5's select binding does not respond to $state changes triggered inside $effect. Instead:

Use selected on <option> + onchange on <select>:

<select name="solfeggioFrequencyId" onchange={(e) => freq = (e.target as HTMLSelectElement).value}>
	<option value="">-- Select --</option>
	{#each options as opt (opt.value)}
		<option value={opt.value} selected={String(opt.value) === freq}>{opt.label}</option>
	{/each}
</select>

This pattern applies to ALL <select> elements in form components. The value={stateVar} prop directive also does not work reliably — always use selected on options.

bind:value on <input> works fine

Unlike <select>, bind:value on <input type="text"> works correctly with $effect-driven state. Feel free to use it for text inputs.

$props() destructuring

Always destructure $props() with defaults:

let {
	record = {} as Record<string, unknown>,
	fieldErrors = {} as Record<string, string>,
	isSuper = false,
}: { record?: Record<string, unknown>; … } = $props();

The type annotation on the destructuring object is REQUIRED for Svelte 5 to infer prop types. Default values are used when the parent doesn't pass that prop.

Component mocking for client tests

When mocking a Svelte 5 component in vi.mock, use a function that takes ($anchor, props):

vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
	default: ($anchor: Element, _props: Record<string, unknown>) => {
		const div = document.createElement('div');
		div.setAttribute('data-testid', 'tiptap-mock');
		$anchor.replaceWith(div);
	}
}));

Testing Patterns & Gotchas

What tests to write

For every admin route, write BOTH:

  1. Server test (page.server.spec.ts): Test the load function and all actions (upsert, delete). Cover role-based access control (Super vs non-Super content stripping, rejection of unauthorized mutations). Mock $lib/db and $lib/svelte-admin/crud.

  2. Client test (Component.svelte.spec.ts): Test the form component rendering in Playwright/Chromium. Cover:

    • All fields render
    • Pre-fill on edit (name input, select values)
    • Conditional field visibility (e.g. content hidden for non-Super)
    • Submit button disabled/enabled by dirty state
    • No visual regression testing — only functional assertions.

Available Playwright/Vitest Browser Locator API

These methods are available on page from vitest/browser:

Method Works? Example
getByRole ✅ page.getByRole('textbox', { name: 'Name' })
getByText ✅ page.getByText('Content')
getByPlaceholder ✅ page.getByPlaceholder('Email Address')
getByAltText ✅ page.getByAltText('Sign In with Google')
getByTestId ✅ page.getByTestId('tiptap-mock')
getByLabel ❌ Not available in this version
getByDisplayValue ❌ Not available in this version

For label-identified elements, use getByRole with name option:

  • Text inputs → getByRole('textbox', { name: /^Name$/ })
  • Selects → getByRole('combobox', { name: /^Frequency$/ })
  • Buttons → getByRole('button', { name: 'Save' })

Always use regex { name: /^Exact$/ } for role queries to avoid strict-mode violations from substring matches (e.g. 'Category' matching both 'Category' and 'Sub-Category').

Querying select values in client tests

Since bind:value on selects doesn't work with $effect-driven state, and we use selected on <option> instead, you can check the select's value directly:

const el = (await page
	.getByRole('combobox', { name: /^Frequency$/ })
	.element()) as HTMLSelectElement;
expect(el.value).toBe('2');

The .element() call returns the underlying DOM element. This works synchronously because selected is set during render.

Server test patterns

Always call vi.clearAllMocks() in beforeEach.

Mock $lib/db with the exact Prisma model methods the code under test uses:

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() }
	}
}));

Mock $lib/svelte-admin/crud when the route uses custom upsert/delete logic:

vi.mock('$lib/svelte-admin/crud', () => ({
	loadRecords: vi.fn(),
	deleteRecord: vi.fn(),
	superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
}));

Use createLocals(mockUser({ roleId: 4 }) as any) to simulate different user roles. Use createFormRequest({ name: 'test' }) for form submissions. Use .catch((e) => e) + isHttpError() for error assertions.

createLocals nuances

  • createLocals() (no arg) → unauthenticated (session is undefined).
  • createLocals(mockUser() as any) → authenticated with the mock user object.
  • Cast to any when passing to load/action functions: { locals: createLocals(mockUser()) } as any.

vi.mock hoisting

All vi.mock() calls must be at the top level of the test file. Values needed inside vi.mock factories must use vi.hoisted():

const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
vi.mock('$lib/some-module', () => ({ default: mockFn }));

Imports of the mocked module must come AFTER vi.mock() calls.


Admin Form Architecture

Per-route form components

Each admin route under admin/[model]/ has its own form component (e.g. ProclamationForm.svelte). These components:

  • Receive record, fieldErrors, formError, relationOptionsData, and model-specific extras (e.g. categories, isSuper) as props.
  • Use $state + $effect for field values (see "Svelte 5 Runes Gotchas" above).
  • Use selected on <option> for selects.
  • Use oninput on the <form> to track dirty state: <form oninput={markDirty}>.
  • Submit via action="?/upsert" with SvelteKit's use:enhance.

Server actions

Routes with custom requirements (like proclamation's Super-only restriction) override the generic upsertRecord/deleteRecord with their own actions. The generic functions in crud.ts are still available and used by simpler routes.

Super-only proclamation restriction

  • Load: content field is stripped from records for non-Super users before returning.
  • Upsert: returns { success: false, error: 'Only Super admins can edit proclamations' } for non-Super.
  • Delete: same early return for non-Super; delegates to deleteRecord for Super.
  • Content field in form: wrapped in {#if isSuper} block — hidden from non-Super users.
  • Row click / New button: disabled for non-Super in +page.svelte.

Dirty-state submit pattern

When editing an existing record (record.id is truthy), the submit button is disabled until the user modifies at least one field. The form tracks dirtiness with let dirty = $state(false) and a markDirty() handler:

<form oninput={markDirty}>
	<button disabled={!!record?.id && !dirty}>Save</button>
</form>

The $effect resets dirty = false on mount (when the record prop changes).


Generated / build artifacts

  • .svelte-kit/ — SvelteKit generated types (gitignored)
  • src/lib/generated/prisma/ — Prisma client output (not gitignored; regenerate with npx prisma generate)

API v1 Endpoints

Route Method Auth Description
/api/v1/categories GET API key List all proclamation categories
/api/v1/frequencies GET API key List all solfeggio frequencies
/api/v1/proclamations GET API key List proclamations (with optional ?key + ?email filter)
/api/v1/license POST API key Validate key: { license_key, email_address }
/api/v1/automations/create/account POST None GHL webhook: create user + license key + emails

GHL Webhook — Auto Account Creation

POST /api/v1/automations/create/account — excluded from API key auth in hooks.server.ts.

Payload:

{ "email": "...", "name": "...", "subscription_level": "Basic" }

subscription_level must match a SubscriptionLevel.name in the database.

Flow:

  1. If user email doesn't exist → auth.api.signUpEmail() with random temp password
  2. Query Verification table for the auto-generated verification token → construct verify URL
  3. Send automated-user-registration Resend template (variables: firstName, tempPassword, verifyUrl, loginUrl)
  4. Create LicenseKey record (active: true, level: subscription_level)
  5. Send license-key-delivery Resend template (variables: firstName, license_key)
  6. If user already exists → skip signup, just create key + send delivery email

Better Auth email callbacks (sendVerificationEmail, sendResetPassword) use void + .catch() — template send failures are logged, never crash the server.

Resend Email Templates

Template ID Variables Subject (API-level)
automated-user-registration firstName, tempPassword, verifyUrl, loginUrl "Your account is ready"
license-key-delivery firstName, license_key "Your Rhemawave License Key"
email-verification-email target_email_address, verification_link "Verify your email address"

Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal {{{varName}}} text.

Controlled by src/lib/server/resend.ts (sendMail) and src/lib/server/email-verification.ts.

AdminTable Drag-and-Drop

AdminTable supports full-<tr> drag-and-drop reorder via props:

  • draggable: boolean — enables drag handles on rows
  • onReorder: (ids: string[]) => Promise<void> — called after drop with new row ID order
  • copyableFields: string[] — field keys to show a copy-to-clipboard icon

Pattern: $derived + dragOverride — no $effect sync. handleDragOver always calls preventDefault() for reliable drop targets. handleDrop awaits onReorder then calls invalidateAll().

See src/lib/components/admin/AdminTable.svelte and src/routes/(protected)/admin/proclamation-categories/+page.svelte for example usage.

License Key Admin

  • Create: /admin/license-keys form → ?/createKey action. Requires email + subscription level. Generates key, persists to DB, emails the key via Resend template.
  • Replace: Table row action → ?/replace action. Deletes old key, creates new one with same user + level. No email sent.
  • Subscription level stored as SubscriptionLevel.name string (not a FK).

Key Generation

  • License keys: 16 uppercase hex chars in XXXX-XXXX-XXXX-XXXX format. crypto.randomUUID() → strip dashes → uppercase → 4x4 chunks. Level stored as SubscriptionLevel.name string (not FK).
  • API keys: sk_ + 64 random hex chars. SHA-256 hashed for storage; first 11 chars stored as keyPrefix for identification.