Update AGENTS.md

This commit is contained in:
NeilClack
2026-07-16 20:23:59 -05:00
parent 2d0cf98ed3
commit f5896aa575
5 changed files with 1351 additions and 284 deletions
+328 -284
View File
@@ -1,363 +1,407 @@
# portal.Rhemawavetech.com
You're right — I gave you the optimized root content earlier, but I did not provide it cleanly as a single copy/paste artifact. Here it is.
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
Save this as:
## Quick start
```text
AGENTS.md
```
at your project root:
````text
# Rhemawave Portal AI Guide
## Mission
You are working on portal.Rhemawavetech.com.
Act as a senior engineer making complete, production-ready changes.
Do not stop after analysis.
Do not provide a plan and wait.
Do not describe what should be done instead of doing it.
When asked to make a change:
1. Inspect the relevant code.
2. Understand the existing implementation.
3. Make the change.
4. Validate the result.
5. Fix any problems introduced.
6. Report what changed and what was verified.
A task is incomplete until the implementation is finished and verified.
---
# Working Style
## Focused Context
Do not load the entire repository into context.
Use a focused approach:
1. Identify relevant files.
2. Read only necessary code.
3. Find existing patterns.
4. Make the smallest correct change.
Avoid:
- broad rewrites
- unrelated cleanup
- speculative refactors
- introducing unnecessary dependencies
Prefer:
- small diffs
- existing conventions
- explicit implementations
---
# Development Loop
Always follow this workflow:
## 1. Understand
Before editing:
- Locate relevant files.
- Inspect current behavior.
- Find similar working examples.
- Identify constraints.
## 2. Implement
Make the smallest correct change.
Follow existing architecture.
Do not replace working patterns with new abstractions unless required.
## 3. Verify
Run appropriate checks:
```sh
npm install # engine-strict=true in .npmrc
npm run prepare # svelte-kit sync (generate types)
npm run dev # vite dev on :5173
```
npm run check
npm run lint
npm run test
````
Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
Use narrower commands when appropriate.
## Commands
## 4. Complete
| 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) |
If validation fails:
## Testing quirks
* Diagnose the failure.
* Fix the issue.
* Run validation again.
- **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.).
Never finish with known failures.
---
## Svelte 5 Runes Gotchas & Patterns
# Project Overview
### `$state` initialization vs `$effect` sync
Application:
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`**:
portal.Rhemawavetech.com
```svelte
<script lang="ts">
let { record = {} as Record<string, unknown> } = $props();
Stack:
let name = $state('');
let freq = $state('');
* SvelteKit 2
* Svelte 5
* TypeScript
* Tailwind CSS v4
* Prisma 7
* PostgreSQL
* Better Auth
* Vitest
* Playwright
// Sync local state when prop changes
$effect(() => {
name = String(record.name ?? '');
freq = String(record.solfeggioFrequencyId ?? '');
});
</script>
Package manager:
npm
---
# Quick Start
Install:
```sh
npm install
```
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).
Generate types:
### `$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:
```svelte
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 ?? '');
// …
});
```sh
npm run prepare
```
Both effects run synchronously during mount, so by the time the browser paints, all state is consistent.
Run development:
### `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>`:**
```svelte
<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>
```sh
npm run dev
```
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.
Required environment:
### `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:
```svelte
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)`:
```ts
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);
}
}));
```text
DATABASE_URL
BETTER_AUTH_SECRET
BETTER_AUTH_URL
```
---
## Testing Patterns & Gotchas
# Architecture Map
### What tests to write
## Routes
For every admin route, write BOTH:
Protected routes:
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:
```ts
const el = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
expect(el.value).toBe('2');
```text
(protected)/
```
The `.element()` call returns the underlying DOM element. This works synchronously because `selected` is set during render.
Require authentication and profile.
### Server test patterns
User routes:
**Always call `vi.clearAllMocks()` in `beforeEach`.**
Mock `$lib/db` with the exact Prisma model methods the code under test uses:
```ts
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() }
}
}));
```text
(user)/
```
Mock `$lib/svelte-admin/crud` when the route uses custom upsert/delete logic:
User-facing functionality.
```ts
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn(),
deleteRecord: vi.fn(),
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
}));
Admin routes:
```text
admin/
```
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.
Require:
### `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()`:
```ts
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
vi.mock('$lib/some-module', () => ({ default: mockFn }));
```text
roleId <= 3
```
Imports of the mocked module must come AFTER `vi.mock()` calls.
Roles:
```text
SUPER = 1
ADMIN = 2
STAFF = 3
USER = 4
```
---
## Admin Form Architecture
# Database
### Per-route form components
Prisma generated client:
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:
```svelte
<form oninput={markDirty}>
<button disabled={!!record?.id && !dirty}>Save</button>
</form>
```text
src/lib/generated/prisma/
```
The `$effect` resets `dirty = false` on mount (when the record prop changes).
Do not import Prisma from:
```text
node_modules/.prisma/client
```
Database singleton:
```text
src/lib/db.ts
```
---
## Generated / build artifacts
# Authentication
- `.svelte-kit/` — SvelteKit generated types (gitignored)
- `src/lib/generated/prisma/` — Prisma client output (not gitignored; regenerate with `npx prisma generate`)
Server:
```text
src/lib/auth.ts
```
Browser:
```text
src/lib/auth-client.ts
```
Session handling:
```text
src/hooks.server.ts
```
---
## API v1 Endpoints
# Admin Architecture
| 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 |
Admin forms are route-specific.
## GHL Webhook — Auto Account Creation
Example:
`POST /api/v1/automations/create/account` — excluded from API key auth in `hooks.server.ts`.
**Payload:**
```json
{ "email": "...", "name": "...", "subscription_level": "Basic" }
```text
admin/[model]/
ModelForm.svelte
```
`subscription_level` must match a `SubscriptionLevel.name` in the database.
Do not recreate deleted generic components:
**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
```text
AdminForm.svelte
AdminPage.svelte
```
**Better Auth email callbacks** (`sendVerificationEmail`, `sendResetPassword`) use `void` + `.catch()` — template send failures are logged, never crash the server.
Registry metadata exists for:
## Resend Email Templates
* columns
* searches
* relations
| 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" |
Forms are manually implemented per route.
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`.
# Svelte Rules
## AdminTable Drag-and-Drop
This project uses Svelte 5 runes.
AdminTable supports full-`<tr>` drag-and-drop reorder via props:
Use:
- `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
```text
$state()
$derived()
$effect()
$props()
```
**Pattern:** `$derived` + `dragOverride` — no `$effect` sync. `handleDragOver` always calls `preventDefault()` for reliable drop targets. `handleDrop` awaits `onReorder` then calls `invalidateAll()`.
Do not use:
See `src/lib/components/admin/AdminTable.svelte` and `src/routes/(protected)/admin/proclamation-categories/+page.svelte` for example usage.
```text
writable()
derived()
legacy stores
```
## License Key Admin
For Svelte-specific patterns, read:
- **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).
```text
docs/ai/svelte-patterns.md
```
Only load that file when modifying Svelte components or Svelte logic.
---
# Testing Rules
Testing uses:
* Vitest
* Playwright browser testing
For testing patterns, read:
```text
docs/ai/testing-patterns.md
```
Only load that file when modifying tests.
---
# API Rules
For API routes, authentication, keys, or integrations, read:
```text
docs/ai/api.md
```
Only load that file when working on API functionality.
---
# Formatting
Prettier rules:
* tabs
* single quotes
* no trailing commas
* 80 character print width
Follow existing formatting.
---
# Important Commands
Check:
```sh
npm run check
```
Lint:
```sh
npm run lint
```
Format:
```sh
npm run format
```
Tests:
```sh
npm run test
```
Build:
```sh
npm run build
```
---
# Completion Standard
Before claiming a task is complete:
Confirm:
1. Code changes are implemented.
2. Relevant tests/checks were run.
3. Failures were fixed.
4. The final response explains:
* what changed
* files modified
* verification performed
Do not stop at:
* "I found the issue"
* "Here is a plan"
* "The next step would be"
Finish the work.
## 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.