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}) andserver(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/authviavi.mock(). Stub factories insrc/lib/test-utils.ts. - No
.env.test— mocking$lib/dbmakes real DB unnecessary. - Mock
$app/formsfor any component usinguse:enhance— provideenhance: () => () => {}(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 genericAdminForm.svelte/AdminPage.sveltewere 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/(notnode_modules/.prisma/client). Lazy singleton via Proxy insrc/lib/db.ts. - Better-Auth server instance in
src/lib/auth.ts, browser client insrc/lib/auth-client.ts. Session fetched insrc/hooks.server.ts. - Roles: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4).
canAccessAdminchecks roleId <= 3. - Payments are stubbed (
src/lib/payments.ts— setTimeout + console.log). - API keys: SHA-256 hashed before storage (
ApiKeymodel); only prefix (11 chars) stored for identification./api/v1/*requiresAuthorization: Bearer <key>, except/api/v1/automations/(GHL webhook, no API key needed). Rotation: normal (48h grace viaexpiresAt) or emergency (immediate viarevokedAt). 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:
-
Server test (
page.server.spec.ts): Test theloadfunction and allactions(upsert, delete). Cover role-based access control (Super vs non-Super content stripping, rejection of unauthorized mutations). Mock$lib/dband$lib/svelte-admin/crud. -
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 isundefined).createLocals(mockUser() as any)→ authenticated with the mock user object.- Cast to
anywhen 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+$effectfor field values (see "Svelte 5 Runes Gotchas" above). - Use
selectedon<option>for selects. - Use
oninputon the<form>to track dirty state:<form oninput={markDirty}>. - Submit via
action="?/upsert"with SvelteKit'suse: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
deleteRecordfor 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 withnpx 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:
- If user email doesn't exist →
auth.api.signUpEmail()with random temp password - Query
Verificationtable for the auto-generated verification token → construct verify URL - Send
automated-user-registrationResend template (variables:firstName,tempPassword,verifyUrl,loginUrl) - Create
LicenseKeyrecord (active: true,level: subscription_level) - Send
license-key-deliveryResend template (variables:firstName,license_key) - 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 rowsonReorder: (ids: string[]) => Promise<void>— called after drop with new row ID ordercopyableFields: 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-keysform →?/createKeyaction. Requires email + subscription level. Generates key, persists to DB, emails the key via Resend template. - Replace: Table row action →
?/replaceaction. Deletes old key, creates new one with same user + level. No email sent. - Subscription level stored as
SubscriptionLevel.namestring (not a FK).
Key Generation
- License keys: 16 uppercase hex chars in
XXXX-XXXX-XXXX-XXXXformat.crypto.randomUUID()→ strip dashes → uppercase → 4x4 chunks. Level stored asSubscriptionLevel.namestring (not FK). - API keys:
sk_+ 64 random hex chars. SHA-256 hashed for storage; first 11 chars stored askeyPrefixfor identification.