diff --git a/.prettierignore b/.prettierignore index 7d74fe2..30b246c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ bun.lockb # Miscellaneous /static/ + +# Generated code +/src/lib/generated/ diff --git a/AGENTS.md b/AGENTS.md index 45a22ca..59f819a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,11 +9,11 @@ AGENTS.md at your project root: ````text -# Rhemawave Portal AI Guide +# Radiant Wave Portal AI Guide ## Mission -You are working on portal.Rhemawavetech.com. +You are working on portal.radiantwavetech.com. Act as a senior engineer making complete, production-ready changes. @@ -97,9 +97,9 @@ Use narrower commands when appropriate. If validation fails: -* Diagnose the failure. -* Fix the issue. -* Run validation again. +- Diagnose the failure. +- Fix the issue. +- Run validation again. Never finish with known failures. @@ -109,19 +109,19 @@ Never finish with known failures. Application: -portal.Rhemawavetech.com +portal.radiantwavetech.com Stack: -* SvelteKit 2 -* Svelte 5 -* TypeScript -* Tailwind CSS v4 -* Prisma 7 -* PostgreSQL -* Better Auth -* Vitest -* Playwright +- SvelteKit 2 +- Svelte 5 +- TypeScript +- Tailwind CSS v4 +- Prisma 7 +- PostgreSQL +- Better Auth +- Vitest +- Playwright Package manager: @@ -266,9 +266,9 @@ AdminPage.svelte Registry metadata exists for: -* columns -* searches -* relations +- columns +- searches +- relations Forms are manually implemented per route. @@ -309,8 +309,8 @@ Only load that file when modifying Svelte components or Svelte logic. Testing uses: -* Vitest -* Playwright browser testing +- Vitest +- Playwright browser testing For testing patterns, read: @@ -338,10 +338,10 @@ Only load that file when working on API functionality. Prettier rules: -* tabs -* single quotes -* no trailing commas -* 80 character print width +- tabs +- single quotes +- no trailing commas +- 80 character print width Follow existing formatting. @@ -391,17 +391,14 @@ Confirm: 2. Relevant tests/checks were run. 3. Failures were fixed. 4. The final response explains: - - * what changed - * files modified - * verification performed + - what changed + - files modified + - verification performed Do not stop at: -* "I found the issue" -* "Here is a plan" -* "The next step would be" +- "I found the issue" +- "Here is a plan" +- "The next step would be" Finish the work. - - diff --git a/AGENTS.md.bak b/AGENTS.md.bak index 4006341..f75377d 100644 --- a/AGENTS.md.bak +++ b/AGENTS.md.bak @@ -1,4 +1,4 @@ -# portal.Rhemawavetech.com +# portal.radiantwavetech.com SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth. @@ -332,7 +332,7 @@ The `$effect` resets `dirty = false` on mount (when the record prop changes). | 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" | +| `license-key-delivery` | `firstName`, `license_key` | "Your Radiant Wave 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. diff --git a/README.md b/README.md index 9997a89..4c7c922 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Rhema Wave Technologies - User Portal +# Radiant Wave Technologies - User Portal A combination application that combines the main Radiant Wave API with a user-facing account management application, and an admin-facing platform management application. An all-in-one management platform for Radiant Wave and Radiant Wave Technologies. diff --git a/docs/ADMIN_OVERHAUL.md b/docs/ADMIN_OVERHAUL.md new file mode 100644 index 0000000..61c19a7 --- /dev/null +++ b/docs/ADMIN_OVERHAUL.md @@ -0,0 +1,184 @@ +# Admin Overhaul & Bug Fix Plan + +Status: **IN PROGRESS** — checkboxes are ticked as work is completed and committed. + +## Context + +The admin section is mid-refactor. The generic `$lib/svelte-admin/*` CRUD layer and +`AdminTable.svelte` were deleted; the **Profiles** page was rebuilt by hand with +`DataTable.svelte`, `AdminModal.svelte`, and route-specific form component(s). The +remaining admin pages still import the deleted modules, so the app does not compile. + +This plan: + +1. Fixes the reported Profiles-page bugs (stale buttons, unified User/Profile identity, + in-modal status messages, centered modal with backdrop). +2. Rebuilds every other admin page to match the Profiles page look, style, and + functionality — KISS, no clever abstractions. +3. Removes the **Admin dashboard** — the nav links are the navigation. +4. Uses `DataTable` / `AdminModal` / `Toast` as dumb containers; each page owns its + own form logic. + +Roles: `SUPER = 1`, `ADMIN = 2`, `STAFF = 3`, `USER = 4`. Admin area requires `roleId <= 3`. + +## Decisions Locked In + +- **Dashboard**: removed. `/admin` redirects to `/admin/profiles`. +- **Profile email is the identity.** The User dropdown is removed from the Profiles + form. One Email field: + - Linked profile (`userId` set): field shows/edits the User's email; saving + syncs the change to the `User` record. + - Unlinked profile: email lives on the Profile only. + - On save, if an existing `User` matches the entered email, the profile is linked + to it automatically. +- **Users page** is where Users are edited (Name, Email, Role) — not the Profiles page. +- **Status messages live inside modals**, at the top, not in a page-level toast. +- **API Keys**: pending operator review. Applied: compile fix (remove `$lib/svelte-admin/roles` + dependency) + consistent styling. Modal-based rotation flow is deferred (see appendix). + +--- + +## Checklist + +### Phase 0 — Documentation & scaffolding + +- [x] Document this plan at `docs/ADMIN_OVERHAUL.md` (this file) +- [x] Remove Admin dashboard: `/admin` redirects to `/admin/profiles` + - [x] Delete `(protected)/admin/+page.svelte` + - [x] Rewrite `(protected)/admin/+page.server.ts` as a `redirect(307, '/admin/profiles')` + +### Phase 1 — Shared components + +- [x] Rewrite `src/lib/components/admin/AdminModal.svelte` + - [x] Fixed overlay, centered panel (`fixed inset-0 z-50`) + - [x] Dark translucent backdrop (`bg-slate-900/50`), click/`Esc` to close + - [x] Panel: `max-w-2xl`, `max-h-[90vh]`, scrollable body + - [x] Header: title, Delete (when `canDelete && recordId`), close `×` +- [x] Extend `src/lib/components/DataTable.svelte` + - [x] Optional `copyableFields` → copy button per matching cell (`stopPropagation`) + - [x] Optional `draggable` + `onReorder` → drag-handle reorder column +- [x] Restyle `src/lib/components/admin/Toast.svelte` (fixed, dark chip, countdown/dismiss) + +### Phase 2 — Profiles page (reference implementation) + +- [x] `+page.server.ts` + - [x] Remove user-options relation data / `updateUser` action + - [x] New schema: `email` (required), `firstName`, `lastName`, `phoneNumber` + - [x] Upsert: create links to matching `User` by email; update syncs linked `User.email`; + guard `P2002` with friendly message +- [x] `ProfileFields.svelte` + - [x] Single Email field (bound from `user.email ?? record.email`); remove User select +- [x] `CreateProfileModal.svelte` + - [x] Fix skip-first `lastForm` effect bug + - [x] In-modal success banner ("Profile created") then auto-close + - [x] In-modal error banner + field errors +- [x] `EditProfileModal.svelte` + - [x] Fix skip-first `lastForm` effect bug + - [x] Reset sub-state per record (addresses, toggles, errors) on record change + - [x] Remove the "Edit User" / "Save User" section + - [x] In-modal success/error banners; keep open on successful edit + - [x] Clear & close Add-Address form on success; append returned address live +- [x] `+page.svelte` + - [x] Email column shows `user.email ?? email`; remove `users` prop plumbing + - [x] `{#key editRecord?.id}` so the edit modal remounts per record + - [x] Remove page-level `Toast` for modal feedback +- [x] New spec: `profiles/+page.server.spec.ts` + - [x] Create links by matched email; unlinked stores email only + - [x] Update syncs linked `User.email`; rejects email owned by another user + - [x] Validation failures and `P2002` handling + +### Phase 3 — Users page + +- [x] `+page.server.ts` — load users + role options (role-filtered for non-super); + `upsert` name/email/roleId; `delete`; `P2002` guard +- [x] `+page.svelte` — `DataTable` (Name, Email, Role, Created) + edit `AdminModal` + (Name, Email, Role select, Delete) +- [x] Replace `UserForm.svelte` usage (or convert to inline form) — no `svelte-admin` + +### Phase 4 — User Roles page + +- [x] `+page.server.ts` — direct `prisma.userRole` load/upsert/delete (P2003 guard) +- [x] `+page.svelte` — `DataTable` (Id, Name) + modal + +### Phase 5 — Solfeggio Frequencies page + +- [x] `+page.server.ts` — direct `prisma.solfeggioFrequency` load/upsert/delete (P2003 guard) +- [x] `+page.svelte` — `DataTable` (Name, Value) + modal + +### Phase 6 — Proclamation Categories page + +- [x] `+page.server.ts` — direct `prisma.proclamationCategory` load/upsert/delete/reorder + (P2002/P2003 guards, maxSort for new rows) +- [x] `+page.svelte` — draggable `DataTable` + modal (Name), reorder via fetch + invalidateAll + +### Phase 7 — Proclamation Groups page + +- [x] `+page.server.ts` — direct `prisma.proclamationGroup` load/upsert/delete/reorder + (P2002/P2003 guards) +- [x] `+page.svelte` — draggable `DataTable` (Name, Sort) + modal + +### Phase 8 — License Keys page + +- [x] `+page.server.ts` — direct `prisma.licenseKey` load (with user) + `createKey` + `replace` +- [x] `+page.svelte` — `DataTable` with copyable Key column; New-by-email modal; row → Replace modal +- [x] In-modal success/error banners for both flows + +### Phase 9 — Proclamations page + +- [x] `+page.server.ts` — direct load (strip content for non-super) + upsert (Super only, + P2002) + delete (Super only, P2003); category/group/frequency option data +- [x] `+page.svelte` — `DataTable` (Name, Category, Group, Frequency) + modal with + TipTap content editor for Super users +- [x] Update `page.server.spec.ts` and `page.svelte.spec.ts` (drop `svelte-admin` mocks) + +### Phase 10 — API Keys page (operator page) + +- [x] `+page.server.ts` — remove `ROLES` import (`$lib/svelte-admin/roles` deleted); use literal +- [x] `+page.svelte` — consistent styling; keep single-page rotate flow (modal conversion deferred) +- [x] Document deferred modal conversion in appendix + +### Phase 11 — Tests & validation + +- [x] Update `license-keys/page.server.spec.ts` (drop `svelte-admin` mocks) +- [x] `npm run check` +- [x] `npm run lint` (pre-existing `no-explicit-any` spec debt remains — see Appendix C) +- [x] `npm run test` +- [x] `npm run build` (needs env vars exported during `vite build`) +- [x] Final commit; mark every checkbox complete + +--- + +## Appendix A — Profiles bug root causes + +1. **Skip-first `lastForm` bug.** Both modals guard against the initial `null` form + with `if (lastForm === null) { lastForm = form; return; }`, which also swallows the + _first real submit result_ — the first "Add Address" save creates the DB row but the + UI never reacts (no close, no toast, no appended row). Fix: `if (!form || form === lastForm) return;`. +2. **Stale modal state across records.** `{#if editRecord}` keeps the modal mounted + between different rows, while the `seeded` flag never resets — the previous row's + addresses/user data leak in, and `record` stays stale (the reported + "my name didn't update"). Fix: `{#key editRecord?.id}` remount + per-record reseed. +3. **User data is only a snapshot.** The modal rendered `record.user.name / email` + directly. With the unified email model this display goes away; the form fields are + controlled by local `$state` and the server keeps Profile + User in sync. + +## Appendix B — API Keys decision note + +The API Keys page is an operator page (key rotation), not a CRUD table. It does not use +`DataTable`. Recommended future change: present the rotation confirmation and the new +key in shared `AdminModal` panels with in-modal status. That conversion is **deferred** +pending review. What was done now: compile fix (replaced the deleted `ROLES` import) +and consistent styling (simple slate card layout, indigo action buttons, red emergency +button) so the page compiles and matches the site visually. + +## Appendix C — Validation caveats + +1. **`npm run check`** required renaming `src/routes/(protected)/admin/profiles/+page.server.spec.ts` + to `page.server.spec.ts`: SvelteKit reserves files whose names start with `+`. The `+`-prefix + broke `svelte-kit sync` and thus the whole `check`/`build` pipeline. Renamed. +2. **`npm run lint`** still reports 142 `no-explicit-any` errors across 14 pre-existing spec files + (user/account, dashboard, license-keys, login/register/reset, etc.). These predate this overhaul + and are untouched here; fixing them is a separate cleanup. +3. **`npm run build`** needs `BETTER_AUTH_SECRET`/`BETTER_AUTH_URL`/`DATABASE_URL` exported in the + shell, because `src/lib/auth.ts` reads `$env/dynamic/private` (empty at `vite build` time) and + Better Auth rejects the default secret. Export the vars before building. diff --git a/docs/ai/api.md b/docs/ai/api.md index 65ffc45..99397b8 100644 --- a/docs/ai/api.md +++ b/docs/ai/api.md @@ -89,8 +89,8 @@ POST: Payload: { - "license_key": "...", - "email_address": "..." +"license_key": "...", +"email_address": "..." } Requires API key. @@ -153,7 +153,6 @@ Variables: - verifyUrl - loginUrl - license-key-delivery Variables: @@ -161,7 +160,6 @@ Variables: - firstName - license_key - email-verification-email Variables: diff --git a/docs/ai/svelte-patterns.md b/docs/ai/svelte-patterns.md index 8d8a5fc..82b9215 100644 --- a/docs/ai/svelte-patterns.md +++ b/docs/ai/svelte-patterns.md @@ -36,13 +36,13 @@ Always destructure $props() with defaults. Preferred pattern: let { - record = {} as Record, - fieldErrors = {} as Record, - isSuper = false +record = {} as Record, +fieldErrors = {} as Record, +isSuper = false }: { - record?: Record; - fieldErrors?: Record; - isSuper?: boolean; +record?: Record; +fieldErrors?: Record; +isSuper?: boolean; } = $props(); Do not assume props exist. @@ -66,7 +66,7 @@ Correct: let name = $state(''); $effect(() => { - name = String(record.name ?? ''); +name = String(record.name ?? ''); }); Use $effect whenever component state needs to follow changing props. @@ -88,11 +88,11 @@ Example: let dirty = $state(false); $effect(() => { - dirty = false; +dirty = false; }); $effect(() => { - name = String(record.name ?? ''); +name = String(record.name ?? ''); }); This ensures a new record starts clean. @@ -116,9 +116,10 @@ This can fail to update correctly. Use: Use selected on options. diff --git a/docs/ai/testing-patterns.md b/docs/ai/testing-patterns.md index 42146b1..154b2aa 100644 --- a/docs/ai/testing-patterns.md +++ b/docs/ai/testing-patterns.md @@ -30,13 +30,13 @@ Tests are colocated with source files. Client tests: -*.svelte.test.ts -*.svelte.spec.ts +_.svelte.test.ts +_.svelte.spec.ts Server tests: -*.test.ts -*.spec.ts +_.test.ts +_.spec.ts --- @@ -79,7 +79,7 @@ Node. Always reset mocks: beforeEach(() => { - vi.clearAllMocks(); +vi.clearAllMocks(); }); --- @@ -109,15 +109,15 @@ Mock only the methods used. Example: vi.mock('$lib/db', () => ({ - prisma: { - proclamation: { - findMany: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn() - } - } +prisma: { +proclamation: { +findMany: vi.fn(), +findUnique: vi.fn(), +create: vi.fn(), +update: vi.fn(), +delete: vi.fn() +} +} })); Do not create massive unused mocks. @@ -143,7 +143,7 @@ createLocals(mockUser() as any) Specific roles: createLocals( - mockUser({ roleId: 4 }) as any +mockUser({ roleId: 4 }) as any ) --- @@ -172,15 +172,15 @@ Always use exact regex names. Preferred: page.getByRole( - 'textbox', - { name: /^Name$/ } +'textbox', +{ name: /^Name$/ } ) Avoid: page.getByRole( - 'textbox', - { name: 'Name' } +'textbox', +{ name: 'Name' } ) because substring matching can create strict-mode failures. @@ -192,15 +192,15 @@ because substring matching can create strict-mode failures. Because selects use selected options instead of bind:value: const element = - await page - .getByRole( - 'combobox', - { name: /^Frequency$/ } - ) - .element(); +await page +.getByRole( +'combobox', +{ name: /^Frequency$/ } +) +.element(); expect( - (element as HTMLSelectElement).value +(element as HTMLSelectElement).value ).toBe('2'); --- @@ -213,9 +213,9 @@ Use: vi.mock('$lib/components/example.svelte', () => ({ default: ($anchor: Element) => { - const div = document.createElement('div'); - $anchor.replaceWith(div); - } +const div = document.createElement('div'); +$anchor.replaceWith(div); +} })); Imports using mocked modules must come after vi.mock(). diff --git a/docs/api_updates.md b/docs/api_updates.md new file mode 100644 index 0000000..7838cfc --- /dev/null +++ b/docs/api_updates.md @@ -0,0 +1,330 @@ +# API Updates — Category Flattening, Groups, Subscription Level Removal + +**Release:** v2 schema change (migration `20260821000000_flat_categories_groups_drop_subscription_levels`) +**Status:** Breaking changes. Clients must be updated. + +## Summary of changes + +| Endpoint | Change | +| ----------------------------------------- | ------------------------------------------------------------------------------------ | +| `GET /api/v2/categories` | **Breaking**: `parentId` removed from every record. Categories are now a flat list. | +| `GET /api/v2/groups` | **New**: returns the group list (former sub-categories). | +| `GET /api/v2/proclamations` | **Breaking**: `groupId` added; `subscriptionLevelId` removed. | +| `GET /api/v2/subscription-levels` | **Removed**: subscription levels no longer exist. | +| `POST /api/v2/license` | **Breaking**: response no longer contains `subscription_level`. | +| `POST /api/v2/automations/create/account` | **Breaking**: `subscription_level` is no longer accepted or required in the payload. | +| `POST /api/v1/license` | **Breaking**: response no longer contains `subscription_level`. | +| `POST /api/v1/automations/create/account` | **Breaking**: `subscription_level` is no longer accepted or required in the payload. | +| `GET /api/v1/categories` | Payload implicitly changed: rows no longer contain `parentId`. | + +### Data model mapping + +- Former **parent** categories (`Spiritual`, `Physical`, `Mental`, `Emotional`, `Financial`, + `Social`, `Biblical`, ...) became the flat **Category** list. +- Former **child** sub-categories (`General`, `Specific`, `Precise`, `Prayers`, `Scripture`) + became **Groups**. +- A proclamation's former child category becomes its `group`; its former parent category becomes + its `category`. A proclamation may move between categories without changing its group. +- Proclamations that had no sub-category (e.g. under `Custom (Client Specific)`) were assigned to + the `General` group. +- Every proclamation now has exactly one category and one group. + +--- + +## OpenAPI 3.0 specification + +```yaml +openapi: 3.0.3 +info: + title: Radiant Wave Portal API + version: 2.1.0 + description: | + Changes in this release: + * ProclamationCategory hierarchy flattened; parentId removed everywhere. + * New ProclamationGroup resource replaces sub-categories as a movable filter. + * SubscriptionLevel resource and all level-based gating removed. + +paths: + /api/v2/categories: + get: + summary: List categories (flat) + description: > + Returns all proclamation categories as a flat, ordered list. + BREAKING: the previous version returned a hierarchical list where each + record carried a nullable parentId linking children to parents. That + field no longer exists. + tags: [Categories] + responses: + '200': + description: Flat category list. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Category' + examples: + newResponse: + summary: New response + value: + - id: 4 + name: Spiritual + sortOrder: 0 + - id: 2 + name: Physical + sortOrder: 1 + oldResponseDeprecated: + summary: Old (removed) response shape + value: + - id: 5 + name: General + sortOrder: 0 + parentId: 4 + - id: 9 + name: Specific + sortOrder: 1 + parentId: 4 + + /api/v2/groups: + get: + summary: List groups + description: > + NEW ENDPOINT. Returns all proclamation groups ordered by sortOrder. + Groups replace the former sub-categories and act as a movable filter: + a proclamation can be moved from one category to another while keeping + its group. + tags: [Groups] + responses: + '200': + description: Group list. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Group' + example: + - id: 1 + name: General + sortOrder: 0 + - id: 2 + name: Specific + sortOrder: 1 + + /api/v2/proclamations: + get: + summary: List proclamations + description: > + Returns proclamations with their canonical frequency id and optional + color palette. BREAKING: each record now carries groupId (required, + always present) and no longer carries subscriptionLevelId. When the + optional entitlement headers are supplied, only the caller's entitled + proclamations are returned; there is no longer any per-level gating. + tags: [Proclamations] + parameters: + - in: header + name: x-license-key + required: false + schema: { type: string } + description: License key. Must be paired with x-email. + - in: header + name: x-email + required: false + schema: { type: string } + description: Account email. Must be paired with x-license-key. + responses: + '200': + description: Proclamation list. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Proclamation' + examples: + newResponse: + summary: New response + value: + - id: b40fdcb9-703c-420e-af32-a081964c6f08 + name: Peace & Relaxation + content:

The text content

+ categoryId: 4 + groupId: 1 + solfeggioFrequencyId: 5 + colorPalette: + [ + '#32CD32', + '#3CB371', + '#90EE90', + '#98FB98', + '#ADFFA9', + '#C8FFD2', + '#F0FFF0' + ] + oldResponseDeprecated: + summary: Old (removed) response shape + value: + - id: b40fdcb9-703c-420e-af32-a081964c6f08 + name: Peace & Relaxation + content:

The text content

+ categoryId: 5 + solfeggioFrequencyId: 5 + subscriptionLevelId: 1 + '400': + description: Entitlement headers incomplete or invalid. + + /api/v2/subscription-levels: + get: + summary: REMOVED + deprecated: true + description: > + This endpoint was removed. Subscription levels no longer exist in the + data model; clients must stop calling it. + tags: [Removed] + responses: + '404': + description: Route no longer exists. + + /api/v2/license: + post: + summary: Validate a license key + description: > + Validates a key/email pair. BREAKING: the response no longer contains a + subscription_level object. + tags: [License] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [license_key, email_address] + properties: + license_key: { type: string, example: ABCD-EFGH-IJKL-MNOP } + email_address: { type: string, format: email } + responses: + '200': + description: Key is valid. + content: + application/json: + schema: + $ref: '#/components/schemas/LicenseResponse' + examples: + newResponse: + summary: New response + value: + key: ABCD-EFGH-IJKL-MNOP + email_address: user@example.com + oldResponseDeprecated: + summary: Old (removed) response shape + value: + key: ABCD-EFGH-IJKL-MNOP + email_address: user@example.com + subscription_level: + id: 2 + name: Advanced + description: General + Specific + '400': + description: Validation failure (missing fields, unknown key, wrong owner). + + /api/v2/automations/create/account: + post: + summary: Create account + license key (GoHighLevel automation) + description: > + Creates a portal account when needed and issues a license key. + BREAKING: subscription_level was removed from the request contract. The + field is neither required nor validated anymore; sending it is ignored. + Signature headers (x-timestamp, x-nonce, x-signature) are unchanged. + tags: [Automations] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + name: { type: string } + responses: + '200': + description: Account and license key created. + '400': + description: Missing/invalid email. + '401': + description: Invalid signature. + +components: + schemas: + Category: + type: object + required: [id, name, sortOrder] + properties: + id: { type: integer } + name: { type: string } + sortOrder: { type: integer } + + Group: + type: object + required: [id, name, sortOrder] + properties: + id: { type: integer } + name: { type: string } + sortOrder: { type: integer } + + Proclamation: + type: object + required: [id, name, content, categoryId, groupId, solfeggioFrequencyId] + properties: + id: + type: string + format: uuid + name: { type: string } + content: { type: string, description: HTML formatted content. } + categoryId: + type: integer + description: Flat category this proclamation belongs to. + groupId: + type: integer + description: > + Group membership (movable filter). Independent of categoryId; + changing the category does not change the group. + solfeggioFrequencyId: + type: integer + minimum: 1 + maximum: 9 + description: Canonical kiosk frequency id (not the DB autoincrement id). + colorPalette: + type: array + items: { type: string } + minItems: 7 + maxItems: 7 + description: Present only when a palette exists for the frequency. + + LicenseResponse: + type: object + required: [key, email_address] + properties: + key: { type: string } + email_address: { type: string, format: email } +``` + +--- + +## Client migration checklist + +1. **Kiosk / API consumers** + - Stop reading `parentId`; treat `/api/v2/categories` as flat. + - Fetch `/api/v2/groups` once at startup for the filter list. + - Read `groupId` from each proclamation record for filtering. + - Remove all references to `subscriptionLevelId` and `/api/v2/subscription-levels`. + - Remove `subscription_level` handling from license validation responses. + +2. **GoHighLevel automation** + - Remove `subscription_level` from the account-creation webhook payload + (optional but recommended; the field is now ignored). + +3. **Admin portal** + - Manage categories under Admin → Proclamation Categories (flat list, drag to reorder). + - Manage groups under Admin → Proclamation Groups (drag to reorder). + - Proclamations now select one Category and one Group independently. diff --git a/package-lock.json b/package-lock.json index f309115..e91b954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "portal.Rhemawavetech.com", + "name": "portal.radiantwavetech.com", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "portal.Rhemawavetech.com", + "name": "portal.radiantwavetech.com", "version": "0.0.1", "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/package.json b/package.json index ce96aec..94b4727 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "portal.Rhemawavetech.com", + "name": "portal.radiantwavetech.com", "private": true, "version": "0.0.1", "type": "module", @@ -67,4 +67,4 @@ "sveltekit-superforms": "^2.30.2", "zod": "^4.4.3" } -} \ No newline at end of file +} diff --git a/portal_api_v2_schema.md b/portal_api_v2_schema.md deleted file mode 100644 index fea689b..0000000 --- a/portal_api_v2_schema.md +++ /dev/null @@ -1,756 +0,0 @@ -# RadiantWave Portal API v2 Schema - -This document defines the **required** API surface for the RadiantWave kiosk -application and serves as the implementation contract for the portal -(`../portal`). - -It supersedes `payloads.md` and `ApiUpdates.md`, which described a v1 API that -does not meet the application's requirements. - ---- - -## Background and constraints - -- The kiosk application (`this repo`) talks to - `https://portal.radiantwavetech.com/api/v1`. -- The **existing v1 routes must not change**: previously shipped kiosk binaries - depend on them. The v1 routes and their exact request/response shapes stay - frozen. -- To bring the API into compliance with this application's expectations we - introduce a **new, versioned API under `/api/v2`**, and migrate the kiosk - client to call `/api/v2` instead of `/api/v1`. -- The kiosk client currently uses a single base URL - (`src/network/api_client.h` → `API_BASE_URL`). All endpoint paths are - relative to that base, so migrating the base URL is the only client-side - change required. - -Compliance means: every endpoint the kiosk parses must return exactly the -fields the kiosk expects, in the shapes documented below. Deviations break -provisioning, content sync, or rendering. - ---- - -## Versioning strategy - -| Route space | Status | Consumer | -|-------------|---------------------------|-------------------------------------------------------| -| `/api/v1/*` | Frozen, never modified | Old/shipped kiosk binaries, GoHighLevel automation | -| `/api/v2/*` | New (this document) | Current kiosk application (after base-URL migration) | - -Rules: - -1. Never change the behavior or response shape of an existing `/api/v1/*` - route. If a v1 fix is required, fix it in v2. -2. New capabilities ship under `/api/v2` only. -3. A consumer may pin to the major version it was built against. The kiosk - pins to `/api/v2`. - ---- - -## General conventions (v2) - -- Base URL: `https://portal.radiantwavetech.com/api/v2`. -- All routes require HTTPS (TLS 1.2+; TLS 1.3 recommended). -- Success responses are JSON with `content-type: application/json`. -- Error bodies use the shape `{"message": "..."}` for route-handler errors and - `{"error": "..."}` for the authentication layer (see Security). -- Any response code outside the documented success code is a failure; the - kiosk surfaces `message`/`error` text when present. -- API keys are sent as `Authorization: Bearer sk_` and are **required on - every v2 route** except the signed onboarding route (below). -- Credentials (license keys, emails) must **never** be passed in the query - string. v1 accepted `?email=&key=`; v2 must not. -- Request body sizes are capped (see Security); oversized bodies are rejected - with `413`. - ---- - -## Canonical data model: solfeggio frequencies - -The kiosk uses a fixed 9-preset frequency scheme. Frequency ids must be -**canonical** — they appear on proclamations and color palettes and must match -the kiosk's own `frequencyIdForHz()` mapping: - -| Canonical id | Hz | Frequency (kiosk label) | -|--------------|------|------------------------------| -| 1 | 174 | Grounding / Relief (285 Hz is an alias of id 1) | -| 2 | 396 | Root | -| 3 | 417 | Sacral | -| 4 | 432 | Clarity / Wellbeing | -| 5 | 528 | Solar Plexus (fallback palette) | -| 6 | 639 | Heart | -| 7 | 741 | Throat | -| 8 | 852 | 3rd Eye | -| 9 | 963 | Crown | - -**Compliance gap in v1:** the portal's `SolfeggioFrequency` table stores 7 -rows whose ids do **not** match this scheme (id 1 = 936 Hz, id 5 = 528 Hz, -id 7 = 396 Hz, etc.). Because proclamations and color palettes are keyed by -frequency id, returning portal ids would make the kiosk look up the wrong -palette (e.g. a 396 Hz proclamation would render with the 741 Hz palette). - -**v2 requirement:** every `solfeggioFrequencyId` emitted by v2 must be a -canonical id (1–9) whose `value` resolves through the kiosk map above. -Frequencies that have no canonical id (e.g. 936 Hz) must not be referenced by -any v2 proclamation or palette. See Implementation notes for the reconciling -strategy. - ---- - -## Endpoints - -### 1. `GET /api/v2/frequencies` - -Lists the canonical solfeggio frequencies. - -Request: - -``` -GET /api/v2/frequencies -Authorization: Bearer sk_ -``` - -Response (200): - -```json -[ - { "id": 1, "name": "Grounding / Relief", "value": 174 }, - { "id": 2, "name": "Root", "value": 396 }, - { "id": 3, "name": "Sacral", "value": 417 }, - { "id": 4, "name": "Clarity / Wellbeing", "value": 432 }, - { "id": 5, "name": "Solar Plexus", "value": 528 }, - { "id": 6, "name": "Heart", "value": 639 }, - { "id": 7, "name": "Throat", "value": 741 }, - { "id": 8, "name": "3rd Eye", "value": 852 }, - { "id": 9, "name": "Crown", "value": 963 } -] -``` - -Contract (kiosk `parseFrequenciesJson`): - -- `id`: int, 1–9, **non-zero** (entries with id 0 are skipped). -- `name`: string. -- `value`: int, Hz. Should be a value the kiosk's `frequencyIdForHz()` map - recognizes (174, 285, 396, 417, 432, 528, 639, 741, 852, 963). - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of frequency objects | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 2. `GET /api/v2/categories` - -Lists all proclamation categories (identical shape to v1). - -Request: - -``` -GET /api/v2/categories -Authorization: Bearer sk_ -``` - -Response (200): - -```json -[ - { "id": 4, "name": "Spiritual", "sortOrder": 0, "parentId": null }, - { "id": 5, "name": "General", "sortOrder": 0, "parentId": 4 } -] -``` - -Contract (kiosk `parseCategoriesJson`): - -- `id`: int, **non-zero** (id 0 entries are skipped). -- `name`: string. -- `parentId`: int for children, **`null` for root** categories (kiosk maps - null → 0). -- `sortOrder`: int. - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of category objects | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 3. `GET /api/v2/proclamations` - -Returns the proclamation library. This is the kiosk's primary content sync. - -Request (default: full library): - -``` -GET /api/v2/proclamations -Authorization: Bearer sk_ -``` - -Optional entitlement filter (full records, see below): - -``` -GET /api/v2/proclamations -Authorization: Bearer sk_ -X-License-Key: XXXX-XXXX-XXXX-XXXX -X-Email: user@example.com -``` - -> Header-based credentials replace the v1 `?email=&key=` query form. Do not -> read credentials from the URL. - -Response (200) — every item is a **full proclamation record**: - -```json -[ - { - "id": "b40fdcb9-703c-420e-af32-a081964c6f08", - "name": "Peace & Relaxation", - "content": "

The text content, HTML formatted

", - "categoryId": 5, - "solfeggioFrequencyId": 5, - "subscriptionLevelId": 1, - "colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"] - } -] -``` - -Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`): - -- `id`: string UUID, **required non-empty** (empty ids are skipped). -- `name`: string, used as the display title. Kiosk strips HTML from it. -- `content`: string, **HTML** (the kiosk strips tags and unescapes entities - before rendering). Plain text is also acceptable but HTML is the convention. -- `categoryId`: int, maps to a `/categories` id. -- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)** — see the - canonical model above. -- `subscriptionLevelId`: int or `null`. The kiosk treats missing/null/≤0 as - level 1 (Basic). -- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings, indexed - color 0 (primary) through color 6 (tertiary). When present, the kiosk can - color a proclamation directly from this payload (first proclamation per - frequency wins for palette derivation). When absent, the kiosk falls back to - `/color-palettes`, then to its own 528 Hz seed. - -With entitlement headers (`X-License-Key` + `X-Email`): - -- Validate the key exists, is active, and belongs to the email's user. -- Return the **full proclamation records** the user may access (matching the - record shape above), **not** raw `UserProclamations` join-table rows. - (v1 returned join rows, which the kiosk parser cannot consume — this is the - primary v1 compliance defect this endpoint fixes.) - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of proclamation records | Success | -| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist | -| 400 | `{"message": "this key doesn't appear to belong to you"}` | Key exists but owned by a different user | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 4. `POST /api/v2/license` - -Validates a license key / email pair and returns the full subscription level. - -Request: - -``` -POST /api/v2/license -Content-Type: application/json -Authorization: Bearer sk_ -``` - -```json -{ - "license_key": "XXXX-XXXX-XXXX-XXXX", - "email_address": "user@example.com" -} -``` - -Response (200): - -```json -{ - "key": "XXXX-XXXX-XXXX-XXXX", - "email_address": "user@example.com", - "subscription_level": { - "id": 2, - "name": "Advanced", - "description": "General + Specific" - } -} -``` - -`subscription_level` is **always an object** `{ id, name, description }`. If -the license's level name does not match any `SubscriptionLevel` row, fall back -to a structurally consistent object: - -```json -{ - "key": "XXXX-XXXX-XXXX-XXXX", - "email_address": "user@example.com", - "subscription_level": { "id": null, "name": "SomeCustomLevel", "description": null } -} -``` - -Contract (kiosk `parseLicenseJson`): - -- `key`: string, **required non-empty**. -- `email_address`: string. -- `subscription_level.name`: string, **required** for a successful validation - (the kiosk persists id/name/description into its local `system_kv` table and - uses the level for proclamation gating and the Home footer). - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | success object | License valid and owned by the email | -| 400 | `{"message": "license_key is required"}` | Missing `license_key` | -| 400 | `{"message": "email_address is required"}` | Missing `email_address` | -| 400 | `{"message": "license key not found"}` | No key with that value | -| 400 | `{"message": "license key is not active"}` | Key exists but inactive | -| 400 | `{"message": "user does not exist"}` | No user with that email | -| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user | -| 401 | `{"error": "..."}` | Missing/invalid API key | - -> The kiosk client also tolerates the v1 string form and flat -> `subscription_level_id` / `subscription_level_description` fields. v2 should -> always send the object form; the fallbacks exist only for transitional -> compatibility. - ---- - -### 5. `GET /api/v2/subscription-levels` - -Lists the subscription levels. - -Request: - -``` -GET /api/v2/subscription-levels -Authorization: Bearer sk_ -``` - -Response (200): - -```json -[ - { "id": 1, "name": "Basic", "description": "General only" }, - { "id": 2, "name": "Advanced", "description": "General + Specific" }, - { "id": 3, "name": "Elite", "description": "General + Specific + Precise" } -] -``` - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of `{id, name, description}` | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 6. `GET /api/v2/color-palettes` - -Exposes the 7-color palette for every canonical frequency so the portal admin -can update colors without a kiosk code release. - -Request: - -``` -GET /api/v2/color-palettes -Authorization: Bearer sk_ -``` - -Response (200) — one entry per canonical frequency (1–9): - -```json -[ - { - "solfeggioFrequencyId": 1, - "colors": ["#8B6914", "#B8860B", "#DAA520", "#E8B64C", "#FFD700", "#FFE9A8", "#FFF4CC"] - }, - { - "solfeggioFrequencyId": 5, - "colors": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"] - } -] -``` - -Contract (kiosk `parseColorPalettesJson`): - -- `solfeggioFrequencyId`: int, **non-zero**, canonical id. -- `colors`: array of `"#RRGGBB"` (or `"RRGGBB"`) strings, exactly 7. - -Kiosk fallback: `db::getPaletteForFrequencyId()` falls back to the 528 Hz -palette for any frequency id with no rows; the startup seed backfills missing -ids with `INSERT OR IGNORE`, so portal values win after a successful sync. - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | array of `{solfeggioFrequencyId, colors}` | Success | -| 401 | `{"error": "..."}` | Missing/invalid API key | - ---- - -### 7. `POST /api/v2/automations/create/account` - -Creates (or reuses) a user account and issues a brand-new active license key. -The key is **not returned** — it is emailed to the user. This is the only v2 -route that does not require a bearer API key; instead it requires an -**HMAC-SHA256 signature** (see Security). It is used for first-run provisioning -and by the GoHighLevel automation. - -Request: - -``` -POST /api/v2/automations/create/account -Content-Type: application/json -X-Timestamp: -X-Nonce: -X-Signature: -``` - -```json -{ - "email": "user@example.com", - "name": "Optional Display Name", - "subscription_level": "Basic" -} -``` - -- `email` (required, string). -- `name` (optional, string; defaults to the local part of `email`). -- `subscription_level` (required, string) — one of `Basic`, `Advanced`, - `Elite` (must match a `SubscriptionLevel.name`). - -Response (200): - -```json -{ - "success": true, - "message": "Account and license key created", - "user_id": "uuid" -} -``` - -Errors: - -| Code | Body | Meaning | -|------|------|---------| -| 200 | success object | Account (+ license key) created | -| 400 | `{"message": "email is required"}` | Missing/empty `email` | -| 400 | `{"message": "subscription_level is required"}` | Missing/empty `subscription_level` | -| 400 | `{"message": "subscription_level \"\" not found"}` | Unknown level name | -| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature | -| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email | -| 500 | `{"message": "Failed to create account"}` | User creation failed | -| 500 | `{"message": "Failed to find or create user"}` | User could not be located | - -Behavior (unchanged from v1): - -- If the email already belongs to a user, no new user is created — only a new - license key for that user. -- A new user receives a temporary-password/verify email and a license-key - email. Sending up to two emails, so this endpoint must be rate-limited - aggressively. - ---- - -## Compliance contract (kiosk → v2) - -| Kiosk consumer | v2 endpoint | Required fields / shape | -|----------------|-------------|-------------------------| -| `sync::syncProclamationsFromApi` (Home + ProclamationOptions sync) | `GET /proclamations`, `GET /categories` | Full proclamation records; `id`, `name`, `content`, `categoryId`, `solfeggioFrequencyId` (canonical), `subscriptionLevelId` | -| `license::validateStoredLicense` (License Key provisioning page) | `POST /license` | `subscription_level` as `{id, name, description}` object | -| `api::fetchColorPalettes` / `sync::syncColorPalettesFromApi` (readiness, to be wired) | `GET /color-palettes` | `[{solfeggioFrequencyId, colors[7]}]` | -| `sync::syncProclamationPalettesFromApi` (readiness, to be wired) | `GET /proclamations` | Embedded `colorPalette` array per proclamation | -| `api::fetchFrequencies` (readiness) | `GET /frequencies` | Canonical id 1–9 + `value` in Hz | -| Future level-picker UI | `GET /subscription-levels` | `{id, name, description}` | -| Provisioning / GHL automation | `POST /automations/create/account` | `email`, `subscription_level`, optional `name` | - ---- - -## Implementation instructions (portal, `../portal`) - -### Route layout - -Create the v2 route tree alongside the frozen v1 tree: - -``` -src/routes/api/v2/ - frequencies/+server.ts - categories/+server.ts - proclamations/+server.ts - license/+server.ts - subscription-levels/+server.ts - color-palettes/+server.ts - automations/create/account/+server.ts -``` - -### Auth gate (`src/hooks.server.ts`) - -Extend the existing handler so v2 auth is **always enforced** (do not honor -`API_KEYS_ACTIVE` for v2): - -```ts -const isV2 = event.url.pathname.startsWith('/api/v2/'); -const isSignedOnboarding = - event.url.pathname.startsWith('/api/v2/automations/'); - -if (isV2 && !isSignedOnboarding) { - // validateApiKey() ALWAYS runs for v2 — no env-var bypass. - 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' } - }); - } - const record = await validateApiKey(authHeader.slice(7)); - if (!record) { - return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), { - status: 401, - headers: { 'content-type': 'application/json' } - }); - } - return resolve(event); -} -``` - -Keep the existing v1 branch untouched. - -### Shared serializer (`src/lib/server/v2-serializers.ts`) - -Add one module that maps DB rows to the v2 wire format. The critical piece is -frequency-id reconciliation: - -```ts -// Canonical id <- Hz (kiosk frequencyIdForHz map). -const CANONICAL_ID_FOR_HZ: Record = { - 174: 1, 285: 1, 396: 2, 417: 3, 432: 4, - 528: 5, 639: 6, 741: 7, 852: 8, 963: 9 -}; - -function canonicalIdForFrequency(freq: { id: number; value: number; name: string }): number | null { - const id = CANONICAL_ID_FOR_HZ[freq.value]; - return id ?? null; // e.g. 936 Hz has no canonical id -> excluded from v2 -} - -export function serializeProclamation(p: Proclamation & { solfeggioFrequency: { value: number } }) { - const solfeggioFrequencyId = CANONICAL_ID_FOR_HZ[p.solfeggioFrequency.value]; - if (!solfeggioFrequencyId) return null; // drop proclamations with no canonical frequency - return { - id: p.id, - name: p.name, - content: p.content, - categoryId: p.categoryId, - solfeggioFrequencyId, - subscriptionLevelId: p.subscriptionLevelId, - // optional; read from the palette source below - colorPalette: paletteFor(p.solfeggioFrequency.value) // 7x "#RRGGBB" or omit - }; -} -``` - -Key points: - -- Derive the emitted `solfeggioFrequencyId` from the joined frequency's - **Hz value**, never from the DB id. This keeps v1 rows (and their ids) - untouched while v2 emits canonical ids. -- Proclamations whose frequency has no canonical id (e.g. 936 Hz) are excluded - from v2 responses (or re-assigned a canonical frequency in data). -- Add the missing frequencies to the DB so content can be authored for them: - `174, 285, 432, 963` (by `value`). Do not delete or renumber the existing - rows that v1 depends on. - -### Color palettes - -Add a portal-editable palette source keyed by canonical frequency, for example -a `colorPalette` JSON column on `SolfeggioFrequency` or a -`ColorPalette`/`ColorPaletteColor` table: - -```prisma -model ColorPalette { - solfeggioFrequencyId Int @id - colors Json // ["#RRGGBB", ... x7] -} -``` - -- Seed all 9 canonical palettes to match the kiosk's current seed - (`src/db/database_manager.cpp` → `seedColorPalettes()`). -- `GET /color-palettes` returns one entry per canonical id. -- `GET /proclamations` embeds the palette for the proclamation's canonical - frequency. - -### License endpoint - -In `license/+server.ts`, resolve the level object like v1 change #1 suggested: - -```ts -const level = await prisma.subscriptionLevel.findFirst({ - where: { name: storedKey.level } -}); - -subscription_level = level - ? { id: level.id, name: level.name, description: level.description } - : { id: null, name: storedKey.level, description: null }; -``` - -### Proclamations entitlement filter - -When `X-License-Key` / `X-Email` headers are present, validate them exactly as -v1 does, but return **full proclamation records** (the user's entitled set via -`UserProclamations` + `Proclamation` include), never join-table rows. - -### Tests - -- Unit-test the serializers with fixtures matching the kiosk's offline tests - in `tests/test_api_client.cpp` (string vs object `subscription_level`, - `subscriptionLevelId: null`, `parentId: null`, 7-color palettes, canonical - frequency mapping incl. 936 → dropped). -- Live smoke tests against `/api/v2` mirroring the existing v1 live tests. - ---- - -## Security: restricting v2 to the kiosk application - -These endpoints should be reachable only from enrolled kiosk devices. v1's -auth could be disabled with `API_KEYS_ACTIVE=false`; v2 must not offer that -bypass. Suggestions below are ordered by impact. - -### 1. Mandatory bearer-key auth on every content route - -- Every v2 route except the signed onboarding route requires - `Authorization: Bearer sk_`. -- Enforce it in `hooks.server.ts` unconditionally (no env toggle). -- Keys are generated with `generateApiKey()`, stored **SHA-256 hashed**, and - only the `sk_xxxxxxxxxx` prefix is ever displayed. Never log or return the - plaintext key. -- Use constant-time comparison / lookup by hash (already the case via - `validateApiKey`). - -### 2. Per-device credentials (enroll each kiosk) - -One shared API key is a single point of failure. Instead: - -- Add a `Device` record (id, serial, model, owner, enrolledAt, apiKeyId) and - issue **one unique key per kiosk** at first boot (or at manufacturing). -- Bind the key to the device: include the device id in a custom header - (`X-Device-Id`) and verify the key belongs to that device. -- A compromised key is then revoked per-device without a global rotation, and - the portal can tell which device made each request. - -### 3. Least privilege / scoped keys - -- Kiosk keys are read-only for content (`/frequencies`, `/categories`, - `/proclamations`, `/color-palettes`, `/subscription-levels`) and may POST to - `/license`. -- Reject any other verb/route combination with 403. Admin and data-mutation - keys are issued separately to portal staff only. - -### 4. Rate limiting per key and per IP - -- Content GETs: ~60/min per key/IP. -- `/license`: ~10/min per key/IP. -- `/automations/create/account`: ~5/min per IP and ~3/day per email. -- Return `429` with `Retry-After`; the kiosk client should back off and retry. -- Slippery handling for repeated failures (rate-limit on failures too). - -### 5. Replay protection / request signing - -- The bearer key alone is replayable if captured. Add to the high-risk routes - (and the onboarding route) an HMAC-SHA256 signature over the canonical - request (method, path, `X-Timestamp`, `X-Nonce`, raw body) using a secret - known only to the device and the server. -- Reject when `|now − X-Timestamp| > 300s` or when a nonce has been seen - before. This defeats capture-and-replay attacks over the network. -- Implement the same scheme for the onboarding route using a shared secret - configured on the portal and the GoHighLevel automation (replacing the - current entirely unauthenticated `/automations/*` exemption). - -### 6. Never put credentials in URLs - -- v1 accepted license keys and emails in the query string, which land in - access logs, proxies, and referrers. v2 reads credentials from the - `Authorization` header and JSON body only. -- Redact `Authorization` values, license keys, and emails in application and - proxy logs. - -### 7. Client fingerprinting and bot rejection - -- The kiosk sends `User-Agent: RadiantWave-Kiosk/0.1`. Require it (optionally - with a signed `X-Client-Id`) and return `403` for generic browsers, `curl`, - and headless scanners. -- Reject request bodies that are not `application/json` where JSON is - expected; enforce request-size limits (reject with `413`). -- At the proxy layer: require TLS 1.2+, prefer 1.3, set HSTS; consider - restricting v2 to expected deployment geographies and blocking TOR/exit - nodes and known hosting/bot ASNs. - -### 8. Input validation and uniform errors - -- Validate each body against a strict schema: email ≤ 254 chars and a valid - shape, `license_key` must match `^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`, - `subscription_level` must equal a known level name, `name` ≤ 120 chars. -- Reject embedded HTML/script in string fields. -- Return uniform `{"message": ...}` bodies; never leak stack traces or SQL - errors. Avoid verbose error differences that enable credential enumeration - (see below). - -### 9. License / onboarding abuse resistance - -- `/license` success/failure is enumerable by design (a fixed key format). At - minimum rate-limit aggressively. Consider returning a generic - `{"message": "invalid credentials"}` (possibly plus a structured `code` - field for the kiosk) so attackers cannot distinguish "user exists" from - "key exists". Keep the specific messages only behind authenticated admin - tooling if they are needed for support. -- `/automations/create/account` mints real license keys and sends email, so - protect it hardest: HMAC signature (above), per-IP and per-email rate - limits, an email-domain/typo check, and a honeypot field. Never expose it - without signature as an unauthenticated "create account" funnel. - -### 10. Monitoring, logging, alerting - -- Log every v2 request as structured data: timestamp, endpoint, method, status, - key prefix, `X-Device-Id`, IP, latency. Never log full keys, emails, or - bodies. -- Alert on: error-rate spikes, repeated 401s, unexpected `/license` volume, - signup bursts, single-device request floods, and revoked-key usage. -- Automatically suspend a device that shows abusive patterns; require - re-enrollment. - -### 11. Key lifecycle - -- Rotate keys on a schedule; support the existing `rotateApiKey` 48-hour grace - and emergency revocation per device. -- Auto-expire keys that have not authenticated in N days. -- Consider an authenticated `/api/v2/auth/refresh` (or device re-enrollment) - so a kiosk can obtain a fresh key, turning a leaked key into a self-healing - problem. - ---- - -## Rollout checklist - -1. Implement the v2 routes and serializers; add unit + live smoke tests. -2. Ship v2 alongside v1 (both live; v1 frozen). -3. Flip the kiosk base URL: `src/network/api_client.h` → - `https://portal.radiantwavetech.com/api/v2` (already done by this change). -4. Run the kiosk live tests with `RW_LIVE_API_KEY` set: - - `./build/test_api_client` - - `./build/test_license_service` -5. Update portal-admin color palettes and verify a fresh kiosk sync picks them - up (now wired via `/color-palettes`). -6. Keep `/api/v1` online until all deployed kiosks are on the v2 build; - monitor v1 traffic and sunset it only after the fleet is migrated. diff --git a/prisma/migrations/20260821000000_flat_categories_groups_drop_subscription_levels/migration.sql b/prisma/migrations/20260821000000_flat_categories_groups_drop_subscription_levels/migration.sql new file mode 100644 index 0000000..1e6c0bc --- /dev/null +++ b/prisma/migrations/20260821000000_flat_categories_groups_drop_subscription_levels/migration.sql @@ -0,0 +1,115 @@ +-- Flatten ProclamationCategory hierarchy into a flat list and introduce +-- ProclamationGroup (formerly child sub-categories). +-- +-- This migration is intentionally NON-DESTRUCTIVE: +-- * ProclamationGroup / Proclamation.groupId are added additively. +-- * Former child categories are ARCHIVED to ProclamationCategoryLegacy +-- before the hierarchy (parentId) is removed, so no category data is lost. +-- * Subscription-level resources (SubscriptionLevel, +-- ProclamationCategorySubscriptionLevel, Proclamation.subscriptionLevelId, +-- LicenseKey.level) are LEFT IN PLACE as unmanaged legacy data. They are no +-- longer modeled by the Prisma schema and are ignored by the application, +-- but their data is preserved and no DROP is attempted on tables the deploy +-- role may not own. + +-- CreateTable +CREATE TABLE "ProclamationGroup" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProclamationGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProclamationGroup_name_key" ON "ProclamationGroup"("name"); + +-- Seed groups from every distinct former sub-category name +INSERT INTO "ProclamationGroup" ("name", "sortOrder") +SELECT "name", + ROW_NUMBER() OVER (ORDER BY "name") - 1 +FROM ( + SELECT DISTINCT "name" FROM "ProclamationCategory" WHERE "parentId" IS NOT NULL +) AS d; + +-- AddColumn (nullable during backfill) +ALTER TABLE "Proclamation" ADD COLUMN "groupId" INTEGER; + +-- Backfill: former child categories become the proclamation's group; the +-- parent becomes its flat category. +UPDATE "Proclamation" AS p +SET "groupId" = g."id", + "categoryId" = c."parentId" +FROM "ProclamationCategory" AS c +JOIN "ProclamationGroup" AS g ON g."name" = c."name" +WHERE p."categoryId" = c."id" AND c."parentId" IS NOT NULL; + +-- Backfill: proclamations that had no group (e.g. 'Custom (Client Specific)') +-- default to the General group, if it exists. +UPDATE "Proclamation" +SET "groupId" = (SELECT "id" FROM "ProclamationGroup" WHERE "name" = 'General') +WHERE "groupId" IS NULL; + +-- Safety net: any proclamation still unmapped takes the first group. +UPDATE "Proclamation" +SET "groupId" = (SELECT "id" FROM "ProclamationGroup" ORDER BY "sortOrder" LIMIT 1) +WHERE "groupId" IS NULL; + +ALTER TABLE "Proclamation" ALTER COLUMN "groupId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Proclamation" ADD CONSTRAINT "Proclamation_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProclamationGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Archive former child categories before removing them, so their data is +-- preserved and recoverable. The legacy table also records each child's +-- parent for audit/recovery. +CREATE TABLE "ProclamationCategoryLegacy" ( + "id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "parentId" INTEGER, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProclamationCategoryLegacy_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "ProclamationCategoryLegacy" ("id", "name", "parentId", "sortOrder") +SELECT "id", "name", "parentId", "sortOrder" +FROM "ProclamationCategory" +WHERE "parentId" IS NOT NULL; + +-- Archive category->subscription mappings for the child categories being +-- removed, so the cascade delete below does not destroy this (deprecated) +-- mapping data. Requires read access to the join table, but never ownership. +CREATE TABLE "ProclamationCategorySubscriptionLevelLegacy" ( + "categoryId" INTEGER NOT NULL, + "subscriptionLevelId" INTEGER NOT NULL, + + CONSTRAINT "ProclamationCategorySubscriptionLevelLegacy_pkey" PRIMARY KEY ("categoryId", "subscriptionLevelId") +); + +INSERT INTO "ProclamationCategorySubscriptionLevelLegacy" ("categoryId", "subscriptionLevelId") +SELECT j."categoryId", j."subscriptionLevelId" +FROM "ProclamationCategorySubscriptionLevel" AS j +JOIN "ProclamationCategory" AS c ON c."id" = j."categoryId" AND c."parentId" IS NOT NULL; + +-- Remove former child categories (their data is archived above; the +-- subscription-level join rows for them are archived above and the +-- SubscriptionLevel table itself is left untouched). +DELETE FROM "ProclamationCategory" WHERE "parentId" IS NOT NULL; + +-- Drop self-relation +ALTER TABLE "ProclamationCategory" DROP CONSTRAINT "ProclamationCategory_parentId_fkey"; + +-- DropIndex +DROP INDEX "ProclamationCategory_parentId_name_key"; + +-- AlterTable +ALTER TABLE "ProclamationCategory" DROP COLUMN "parentId"; + +-- CreateIndex +CREATE UNIQUE INDEX "ProclamationCategory_name_key" ON "ProclamationCategory"("name"); + +-- Subscription-level resources are intentionally left in place (unmanaged). +-- No DROP TABLE / DROP COLUMN / DROP CONSTRAINT is performed here, so this +-- migration never requires ownership of pre-existing subscription tables and +-- never destroys subscription data. diff --git a/prisma/migrations/20260827162542_profile_and_license_key_without_user/migration.sql b/prisma/migrations/20260827162542_profile_and_license_key_without_user/migration.sql new file mode 100644 index 0000000..1baeb0b --- /dev/null +++ b/prisma/migrations/20260827162542_profile_and_license_key_without_user/migration.sql @@ -0,0 +1,28 @@ +-- DropForeignKey +ALTER TABLE "LicenseKey" DROP CONSTRAINT "LicenseKey_userId_fkey"; + +-- DropIndex +DROP INDEX "Profile_userId_key"; + +-- AlterTable +ALTER TABLE "LicenseKey" ADD COLUMN "email" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "email" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "LicenseKey_userId_idx" ON "LicenseKey"("userId"); + +-- CreateIndex +CREATE INDEX "LicenseKey_email_idx" ON "LicenseKey"("email"); + +-- CreateIndex +CREATE INDEX "Profile_userId_idx" ON "Profile"("userId"); + +-- CreateIndex +CREATE INDEX "Profile_email_idx" ON "Profile"("email"); + +-- AddForeignKey +ALTER TABLE "LicenseKey" ADD CONSTRAINT "LicenseKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02ffba9..aecba72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,7 +25,7 @@ model User { role UserRole? @relation(fields: [roleId], references: [id]) sessions Session[] accounts Account[] - profile Profile? + profiles Profile[] licenseKeys LicenseKey[] userProclamations UserProclamations[] @@ -37,28 +37,32 @@ model User { // a bunch of time trying to figure out how to customize the default Better-Auth model. model Profile { id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + email String? firstName String lastName String phoneNumber String ghlContactId String? @map("ghl_contact_id") addresses Address[] + + @@index([userId]) + @@index([email]) } model Address { - id String @id @default(uuid()) - profileId String - profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) - street String - city String - state String - postalCode String - country String - type String @default("Mailing") - isDefaultMailing Boolean @default(false) - isDefaultBilling Boolean @default(false) - isDefaultShipping Boolean @default(false) + id String @id @default(uuid()) + profileId String + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) + street String + city String + state String + postalCode String + country String + type String @default("Mailing") + isDefaultMailing Boolean @default(false) + isDefaultBilling Boolean @default(false) + isDefaultShipping Boolean @default(false) } model Session { @@ -123,9 +127,16 @@ model Proclamation { solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id]) categoryId Int category ProclamationCategory @relation(fields: [categoryId], references: [id]) + groupId Int + group ProclamationGroup @relation(fields: [groupId], references: [id]) userProclamations UserProclamations[] - subscriptionLevelId Int // Keeping old FK for migration stability - subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id]) +} + +model ProclamationGroup { + id Int @id @default(autoincrement()) + name String @unique + sortOrder Int @default(0) + proclamations Proclamation[] } model UserProclamations { @@ -142,40 +153,54 @@ model UserProclamations { model SolfeggioFrequency { id Int @id @default(autoincrement()) name String @unique - value Int @unique + value Int @unique proclamations Proclamation[] } model ProclamationCategory { - id Int @id @default(autoincrement()) - name String - sortOrder Int @default(0) - parentId Int? - parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id]) - children ProclamationCategory[] @relation("CategoryHierarchy") - proclamations Proclamation[] - subscriptionLevels ProclamationCategorySubscriptionLevel[] - - @@unique([parentId, name]) + id Int @id @default(autoincrement()) + name String @unique + sortOrder Int @default(0) + proclamations Proclamation[] } -// Join table for ProclamationCategory -> SubscriptionLevel defaults -model ProclamationCategorySubscriptionLevel { - categoryId Int - category ProclamationCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) +// Archive of the former child sub-categories removed when the +// ProclamationCategory hierarchy was flattened. Kept for recovery/audit. +// `@@ignore` keeps it out of the generated client while Prisma remains aware +// of the table so `migrate dev` does not report it as an untracked table. +model ProclamationCategoryLegacy { + id Int @id + name String + parentId Int? + sortOrder Int @default(0) + + @@map("ProclamationCategoryLegacy") + @@ignore +} + +// Archive of the category->subscription-level mappings for the child +// categories removed when the category hierarchy was flattened. Kept with +// `@@ignore` so Prisma remains aware of the table but it is excluded from the +// generated client and Prisma's own migrations. +model ProclamationCategorySubscriptionLevelLegacy { + categoryId Int subscriptionLevelId Int - subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id], onDelete: Cascade) @@id([categoryId, subscriptionLevelId]) + @@map("ProclamationCategorySubscriptionLevelLegacy") + @@ignore } model LicenseKey { id String @id @default(uuid()) key String @unique active Boolean - level String - userId String - user User @relation(fields: [userId], references: [id]) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + email String? + + @@index([userId]) + @@index([email]) } model ApiKey { @@ -187,14 +212,6 @@ model ApiKey { revokedAt DateTime? } -model SubscriptionLevel { - id Int @id @default(autoincrement()) - name String - description String? - proclamations Proclamation[] - categoryLevels ProclamationCategorySubscriptionLevel[] -} - // Portal-editable 7-color palette per canonical solfeggio frequency id (1-9). // `solfeggioFrequencyId` is the canonical id the kiosk uses (see // v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`. @@ -203,4 +220,4 @@ model ColorPalette { colors Json // ["#RRGGBB", ... x7] @@map("ColorPalette") -} \ No newline at end of file +} diff --git a/prisma/seed.ts b/prisma/seed.ts index fffd785..56f11e5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -72,43 +72,26 @@ const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [ } ]; -const subscriptionLevels = [ - { id: 1, name: 'Basic', description: 'General only' }, - { id: 2, name: 'Advanced', description: 'General + Specific' }, - { id: 3, name: 'Elite', description: 'General + Specific + Precise' } +const proclamationGroups = [ + { name: 'General', sortOrder: 0 }, + { name: 'Specific', sortOrder: 1 }, + { name: 'Precise', sortOrder: 2 }, + { name: 'Prayers', sortOrder: 3 }, + { name: 'Scripture', sortOrder: 4 } ]; +// Flat category list (formerly hierarchical parents). const proclamationCategories = [ - { id: 4, name: 'Spiritual', parentId: null }, - { id: 5, name: 'General', parentId: 4 }, - { id: 9, name: 'Specific', parentId: 4 }, - { id: 40, name: 'Precise', parentId: 4 }, - { id: 2, name: 'Physical', parentId: null }, - { id: 3, name: 'General', parentId: 2 }, - { id: 10, name: 'Specific', parentId: 2 }, - { id: 32, name: 'Precise', parentId: 2 }, - { id: 16, name: 'Mental', parentId: null }, - { id: 30, name: 'General', parentId: 16 }, - { id: 31, name: 'Specific', parentId: 16 }, - { id: 38, name: 'Precise', parentId: 16 }, - { id: 17, name: 'Custom (Client Specific)', parentId: null }, - { id: 19, name: 'Financial', parentId: null }, - { id: 28, name: 'General', parentId: 19 }, - { id: 29, name: 'Specific', parentId: 19 }, - { id: 37, name: 'Precise', parentId: 19 }, - { id: 20, name: 'Emotional', parentId: null }, - { id: 24, name: 'General', parentId: 20 }, - { id: 26, name: 'Specific', parentId: 20 }, - { id: 45, name: 'Precise', parentId: 20 }, - { id: 21, name: 'Social', parentId: null }, - { id: 22, name: 'General', parentId: 21 }, - { id: 23, name: 'Specific', parentId: 21 }, - { id: 39, name: 'Precise', parentId: 21 }, - { id: 41, name: 'Biblical', parentId: null }, - { id: 42, name: 'Personal Growth & Development', parentId: null }, - { id: 43, name: 'Prayers', parentId: 41 }, - { id: 44, name: 'Scripture', parentId: 41 }, - { id: 46, name: 'Sports', parentId: null } + { id: 4, name: 'Spiritual' }, + { id: 2, name: 'Physical' }, + { id: 16, name: 'Mental' }, + { id: 17, name: 'Custom (Client Specific)' }, + { id: 19, name: 'Financial' }, + { id: 20, name: 'Emotional' }, + { id: 21, name: 'Social' }, + { id: 41, name: 'Biblical' }, + { id: 42, name: 'Personal Growth & Development' }, + { id: 46, name: 'Sports' } ]; async function main() { @@ -130,6 +113,15 @@ async function main() { }); } + console.log('Seeding ProclamationGroup...'); + for (const group of proclamationGroups) { + await prisma.proclamationGroup.upsert({ + where: { name: group.name }, + create: group, + update: group + }); + } + console.log('Seeding ProclamationCategory...'); for (const cat of proclamationCategories) { await prisma.proclamationCategory.upsert({ @@ -139,15 +131,6 @@ async function main() { }); } - console.log('Seeding SubscriptionLevel...'); - for (const level of subscriptionLevels) { - await prisma.subscriptionLevel.upsert({ - where: { id: level.id }, - create: level, - update: level - }); - } - console.log('Seeding ColorPalette...'); for (const palette of colorPalettes) { await prisma.colorPalette.upsert({ diff --git a/src/app.html b/src/app.html index b063808..78500ec 100644 --- a/src/app.html +++ b/src/app.html @@ -3,23 +3,10 @@ - %sveltekit.head% - -
%sveltekit.body%
+ +
%sveltekit.body%
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a32201b..ef53546 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -51,6 +51,6 @@ export const auth = betterAuth({ window: 60, max: 100 }, - trustedOrigins: ['http://localhost:5173', 'https://portal.rhemawavetech.com'], + trustedOrigins: ['http://localhost:5173', 'https://portal.radiantwavetech.com'], plugins: [sveltekitCookies(getRequestEvent)] }); diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte new file mode 100644 index 0000000..5e146cb --- /dev/null +++ b/src/lib/components/DataTable.svelte @@ -0,0 +1,215 @@ + + +
+
+

{title}

+ +
+ + {#if onNew} + + {/if} +
+
+ +
+ + + + {#if draggable} + + {/if} + {#each columns as column (column.label)} + + {/each} + + + + {#each rows as record, i (rowKey(record, i))} + handleDragStart(e, i)} + ondragover={(e) => handleDragOver(e, i)} + ondrop={handleDrop} + ondragend={handleDragEnd} + onclick={() => onRowClick?.(record)} + > + {#if draggable} + + {/if} + {#each columns as column (column.label)} + + {/each} + + {:else} + + + + {/each} + +
{column.label}
e.stopPropagation()} aria-hidden="true"> + ⠿ + + {#if copyableFields.includes(column.label) && column.value(record) != null} + + {String(column.value(record))} + + + {:else} + {String(column.value(record) ?? '')} + {/if} +
+ No results found. +
+
+
diff --git a/src/lib/components/admin/AdminModal.svelte b/src/lib/components/admin/AdminModal.svelte index 4c4e06e..430dbc8 100644 --- a/src/lib/components/admin/AdminModal.svelte +++ b/src/lib/components/admin/AdminModal.svelte @@ -17,8 +17,8 @@ deleteAction?: string; } = $props(); - function handleBackdrop(e: MouseEvent) { - if (e.target === e.currentTarget) onClose?.(); + function handleBackdrop() { + onClose?.(); } function handleKeydown(e: KeyboardEvent) { @@ -44,24 +44,27 @@ {#if open}