Compare commits

...
88 Commits
Author SHA1 Message Date
NeilClack 2aa6669007 Migrate actions to new runner 2026-09-29 23:28:50 -05:00
NeilClack 87934dfd42 chore(vscode): remove debugger configuration 2026-09-29 15:22:03 -05:00
NeilClack 220ff92e78 Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid 2026-09-29 15:22:02 -05:00
NeilClack cc4aa8717e chore(vscode): fix dev-server task matcher and add debuggable-Chrome task
Disable Vite's ANSI output (NO_COLOR/FORCE_COLOR) so the background-task
problem matcher's endsPattern reliably matches, fixing the
'Error exists after running preLaunchTask' failure. Add a
'Launch Debuggable Chrome' task (:9222, persistent ~/.chrome-rw-debug
profile) and an attach compound so you can debug the frontend in a Chrome
you control and stay logged in. Update DEBUGGING.md.
2026-09-29 13:41:18 -05:00
NeilClack e9f94b72ee chore: prettier-format captured Portal fixtures 2026-09-29 13:29:55 -05:00
NeilClack 04e1f79f37 chore(vscode): separate backend and frontend debug configs
Backend configs run Vite directly (not via npm run dev) so the Node
inspector owns the process that executes SSR code, with source maps
enabled for .ts server files. Frontend configs launch/attach Chrome.
Adds a Node-inspector dev task, a Full Stack compound, and DEBUGGING.md
documenting the Safari limitation and workflows.
2026-09-29 13:29:55 -05:00
NeilClack bbf4ac12ee Portal: calendar-style weekly schedule grid, responsive on mobile
Replace the stacked per-day lists with a Google-Calendar-style WeekSchedule:
day columns side by side over a shared, sticky time gutter. Click or drag an
empty slot to create a block at that time; click a block to edit it. The
daily schedule reuses the same grid with a single 'Every day' column so both
modes look consistent. On narrow screens the row scrolls horizontally with
short day labels and the gutter pinned, keeping it usable on phones. Also
change the type switcher and copy dialog to enhanced form submissions (no
full-page reload) so they respond immediately. Removes the now-unused
DaySchedule component.
2026-09-29 13:09:48 -05:00
NeilClack bf465eca60 docs: record Phase 5 verification results 2026-09-29 12:48:26 -05:00
NeilClack 6a3ecaf734 docs: Phase 5 finalize contract, add release notes, sync fixtures 2026-09-29 12:47:33 -05:00
NeilClack e6021a6345 test: Phase 5 verification scripts and opt-in e2e integration spec
Adds scripts to seed scenario data, create Proclamations, snapshot schedules,
drive the Kiosk-facing HTTP endpoints, and an opt-in (P5_INTEGRATION=1)
Vitest spec exercising create/type/copy against a scratch database. The
integration spec is skipped by default so the normal suite needs no DB.
2026-09-29 12:46:04 -05:00
NeilClack be303dcd9c docs: finalize Phase 4 schedule copy in the contract 2026-09-29 10:50:18 -05:00
NeilClack fcb5db458b Portal: Copy to… schedule UI
Adds a Copy to… action on a system's schedule with a multi-select of the
account's other systems, labelled by System Name (fallback: key), excluding
the source. Targets that already have a schedule are flagged and require an
explicit Overwrite & copy confirmation. Shows a summary of the systems
updated after success.
2026-09-29 10:49:15 -05:00
NeilClack 242be5d241 Portal: copy action and hasSchedule flag in the schedule loader
Adds a session-authenticated copy action that scopes the source and targets
to the caller's License Keys and returns a summary of the systems updated.
The loader now reports hasSchedule per system so the Copy UI can warn before
overwriting. Adds action tests (auth, cross-account, summary, error).
2026-09-29 10:49:10 -05:00
NeilClack 68a979f1a1 Portal: transactional schedule copy service
copySchedule copies one License Key's Schedule to one or more other keys by
value in a single Prisma transaction (all targets or none), assigning fresh
Block IDs and preserving each block's dynamicAudio and Proclamation order.
Source/targets are scoped to the caller's account; self-copy and empty
target lists are rejected. The resulting schedule is run through the shared
validator before commit. Adds tests: daily/weekly copy, overwrite, multiple
targets, rollback, cross-account rejection, new IDs, changed version/ETag,
copy independence, and Kiosk-payload parity with the shared fixtures.
2026-09-29 10:49:06 -05:00
NeilClack f6c937b892 docs: document Phase 3 schedule endpoint, model, and resolved open items 2026-09-29 10:35:44 -05:00
NeilClack 9a2888af36 Portal: per-system daily/weekly schedule editor
System picker lists the account's License Keys labelled by System Name
(fallback: key). Schedule type selector (Daily|Weekly) with a confirmation
before switching a populated schedule (daily->weekly prefills all seven
days; otherwise clears). Weekly shows a per-day editor. Blocks use
TimeInput, allow adjacent/midnight-crossing ranges (labelled 'ends next
day'), block empty blocks client-side, default dynamic audio on, and show
title/category/group only. Adds gap helper text and an informational
kiosk-timezone note (browser fallback; no external/paid dependency).
2026-09-29 10:28:05 -05:00
NeilClack 3bb8e0189b Portal: per-license schedule service, shared validator, and key-only GET
Implements the shared validateSchedule validator (daily + weekly, midnight
spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a
per-License-Key schedule service (get/set type, CRUD blocks, weekly
prefill with confirmation), the content-hash version/ETag serializer, the
new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null),
and additive evolution of the legacy GET /api/v2/schedule. Adds validator,
serializer (against docs/fixtures), endpoint ETag/304, and service tests.
2026-09-29 10:23:10 -05:00
NeilClack 138bf03ea9 Portal: move Schedule to per-License-Key with daily/weekly and Block.day
Adds Schedule.licenseKeyId (unique, nullable) and Schedule.type, makes the
legacy userId optional/non-unique, adds Block.day and flips Block
dynamicAudio default to true. The migration backfills each existing
per-user schedule onto that account's oldest License Key as a daily
schedule, preserves unmappable schedules (licenseKeyId NULL) with a
NOTICE, and is reversible. Tested up/down against a scratch schema.
2026-09-29 10:11:21 -05:00
NeilClack 25b5eeb144 docs: document System Name PUT endpoint and validation fields in OpenAPI 2026-09-29 09:59:17 -05:00
NeilClack 3c49f425d8 Portal: display System Name read-only on dashboard and admin lists
Show the System Name where license keys are listed, falling back to an
'Unnamed system' indication when unset. No rename UI or mutation path.
2026-09-29 09:51:58 -05:00
NeilClack 8412dfb69c Portal: System Name PUT endpoint and license-validation status
Add PUT /api/v2/licenses/[licenseKey]/system-name (200/400 invalid_name/
404 unknown_license/409 name_taken, idempotent) and extend POST
/api/v2/license with an optional systemName and additive systemNameStatus
(ok/unchanged/conflict/invalid). Validation always succeeds; an omitted
field never changes or clears a name and empty/whitespace is invalid.
Adds server and helper tests covering trimming, bounds, case-insensitive
uniqueness, cross-account reuse, 404, and idempotency.
2026-09-29 09:51:54 -05:00
NeilClack 1d76e8dff9 Portal: add nullable System Name to LicenseKey with per-account unique index
Adds systemName (as typed, trimmed) and systemNameLower (case-folded) to
LicenseKey plus a unique index on (userId, systemNameLower) so names are
case-insensitively unique per account when non-null. Additive and
reversible; migration tested up/down. Regenerates the Prisma client.
2026-09-29 09:51:48 -05:00
NeilClack 179df1e252 Portal: 12-hour schedule times and dynamicAudio default on
Use TimeInput for Block start/end and show the day timeline axis with
AM/PM, so no 24-hour time is displayed in the schedule UI. New Blocks now
default dynamicAudio to true in the modal and server-side when the field
is absent; existing stored Blocks are untouched.
2026-09-29 08:53:22 -05:00
NeilClack 35558b0652 Portal: add reusable 12-hour TimeInput component
TimeInput displays and accepts AM/PM times, offers the 5-minute options as
suggestions, and allows typing any minute 00-59; its value model is a 24h
HH:MM string. Adds isomorphic parseTimeInput/hmToMinutes/fiveMinuteOptions
helpers and unit + component tests for them.
2026-09-29 08:53:17 -05:00
NeilClack 6c89a89050 docs: add Phase 0 schedule API/data contract and fixtures
Add docs/schedule-api-contract.md (current state, time semantics, schedule
data model, validation rules, endpoints, sync behavior, System Name rules,
dynamic audio, backward compatibility, per-phase touchpoints, open items,
changelog) and docs/fixtures/*.json (daily, weekly, none, midnight-crossing,
adjacent, dynamic-audio, and legacy v2 payloads). Kept byte-identical with
the radiantwave repo.
2026-09-29 08:36:31 -05:00
NeilClack dca9c54d83 Merge branch 'dev'
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 3m23s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 41s
2026-09-11 19:07:21 -05:00
NeilClack c19c13b192 Document dynamic audio and add a schedule response example 2026-09-11 18:45:59 -05:00
NeilClack ec826093eb Expose dynamic audio on the v2 schedule API 2026-09-11 18:41:57 -05:00
NeilClack 96a1342c10 Add Enable Dynamic Audio option to schedule blocks 2026-09-11 18:41:53 -05:00
NeilClack 468a65a688 Add dynamic audio flag to schedule blocks 2026-09-11 18:41:46 -05:00
NeilClack e934a98f00 Use a 12-hour clock with AM/PM selects in the block modal 2026-09-11 18:30:26 -05:00
NeilClack 15bb51582b Move Cancel left and rename Back to Accept in the block modal 2026-09-11 18:30:08 -05:00
NeilClack 46d7cf66b8 Render overnight blocks across midnight in the day view 2026-09-11 18:24:54 -05:00
NeilClack 177b7bb13a Flag overnight blocks in the v2 schedule API 2026-09-11 18:24:51 -05:00
NeilClack 750a9286c9 Support overnight blocks in schedule validation 2026-09-11 18:24:48 -05:00
NeilClack c177e7953d Document the v2 schedule endpoint 2026-09-11 18:16:08 -05:00
NeilClack 5f4116b8e8 Add schedule page with block editor and proclamation browser 2026-09-11 18:16:06 -05:00
NeilClack 545fb22910 Add v2 schedule API endpoint 2026-09-11 18:16:04 -05:00
NeilClack 4922e941ae Add schedule validation and service layer 2026-09-11 18:16:00 -05:00
NeilClack e6b492a335 Add schedule data model and migration 2026-09-11 18:15:57 -05:00
NeilClack 051520b941 Fix page refresh issue
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 2m36s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 34s
2026-09-11 16:05:43 -05:00
NeilClack 64715c91d3 Update workflow
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 4m6s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 1m50s
2026-09-11 15:36:03 -05:00
NeilClack 960673bbc6 Add a site-wide dark mode
Build and Push to Digital Ocean Container Registry / build-and-push (push) Failing after 4s
Build and Push to Digital Ocean Container Registry / migrate (push) Skipped
2026-09-11 12:49:46 -05:00
NeilClack a909db39b1 Delete "API Key" page 2026-09-11 12:44:44 -05:00
NeilClack 020289966d Remove "User Roles" admin page 2026-09-11 12:44:25 -05:00
NeilClack fa614b8c37 Fix staff(+) unable to edit or delete addresses 2026-09-11 12:43:41 -05:00
NeilClack cf8fc7b719 Fix users unable to edit or delete their own addresses 2026-09-11 12:42:35 -05:00
NeilClack 848e646880 Remove the extra page separator in the Login page 2026-09-11 12:41:49 -05:00
NeilClack 98d9b095d9 Document v2 API with OpenAPI schema 2026-09-10 16:19:53 -05:00
NeilClack 2bbee98594 Add dedicated v2 proclamation category/group endpoints and catalog specs 2026-09-10 16:19:53 -05:00
NeilClack 4d929df604 Remove stale admin overhaul and API update docs 2026-09-10 16:19:52 -05:00
NeilClack 10aad07e6d Add admin Audit Log page 2026-09-10 15:10:16 -05:00
NeilClack d16965dab6 Add AuditLog table and Prisma audit extension 2026-09-10 15:10:11 -05:00
NeilClack 7596937f30 Enforce roleId checks on all admin page actions 2026-09-10 14:59:32 -05:00
NeilClack 0cb4b2148a Expose roleId on the Better Auth session user 2026-09-10 14:59:28 -05:00
NeilClack 9de0e2e25a Fix Super admins being blocked from editing proclamations 2026-09-10 14:50:08 -05:00
NeilClack 9cd5397395 Add Resend email template HTML files 2026-09-10 14:35:54 -05:00
NeilClack f6b8cad15e Add subject to password reset email 2026-09-10 14:14:45 -05:00
NeilClack 29d53af8dd Remove unauthenticated /send email endpoint 2026-09-10 14:14:44 -05:00
NeilClack 809ced6d8a Automated signups use the registration-email set-password link 2026-09-10 14:08:56 -05:00
NeilClack 84c905c9ff Add shared registration invite helper and use it for admin-created users 2026-09-10 14:08:51 -05:00
NeilClack ae8d661364 Fix reset-password page to look up the reset token correctly 2026-09-10 13:42:36 -05:00
NeilClack 5ec3018f8b Email a set-password link when an admin creates a user 2026-09-10 13:42:33 -05:00
NeilClack 8a3c1587b4 Fix admin modals getting stuck after a successful submit 2026-09-10 13:33:33 -05:00
NeilClack e60d61e90a Fix admin modals replaying old action results after create or edit 2026-09-10 13:26:54 -05:00
NeilClack f1d80279e9 Record last validation time in v2 license endpoint 2026-09-10 13:18:22 -05:00
NeilClack 4979d955c2 Show active check and last verified date on dashboard license table 2026-09-10 13:18:18 -05:00
NeilClack 0175cab0b4 Add lastValidatedAt to license keys 2026-09-10 13:18:15 -05:00
NeilClack 1aecc51bbd Replace account and license-keys pages with profile page and dashboard 2026-09-10 13:11:41 -05:00
NeilClack f50eb34769 Add createdAt to license keys 2026-09-10 13:11:34 -05:00
NeilClack 4a1777de87 Keep legacy subscription tables as unmanaged instead of dropping them 2026-09-10 11:48:49 -05:00
NeilClack d89fc6837c Add default roleId 4 to new users 2026-09-10 11:48:20 -05:00
NeilClack 5e8851e070 Fix lint debt from admin overhaul; make check/lint/test/build green
- Scope @typescript-eslint/no-explicit-any off for *.spec.ts test doubles
- Type enhance() submit callbacks with SubmitFunction in api-keys page
- Drop as-any casts in proclamations upsert by using typed form data
- Fix useless regex escapes in profiles phone validation
- Remove unused locals param from api-keys load
- Use resolve() for admin nav/error links; store literal hrefs in adminModels
- Update README admin section and ADMIN_OVERHAUL lint caveat
2026-09-10 09:43:16 -05:00
NeilClack 91d162c746 Rebuild admin pages around DataTable/AdminModal; restructure categories/groups and v2 groups API
Remove the generic $lib/svelte-admin CRUD layer and AdminTable.svelte; each
admin route now owns its own form/modal logic. Add DataTable.svelte,
superform-errors.ts, and profile-specific modal/field components.

Flatten proclamation categories/groups and drop subscription levels; add the
v2 groups API; update v1/v2 endpoints, serializers, auth, and regenerated
Prisma client with the new migrations.

Document the overhaul plan in docs/ADMIN_OVERHAUL.md.
2026-09-10 09:38:01 -05:00
NeilClack f4e736c92d Update builder name
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 1m56s
Build and Push to Digital Ocean Container Registry / migrate (push) Failing after 10s
2026-08-18 20:14:44 -05:00
NeilClack 5096118ef5 Remove user proclamations page
Users should not be able to view proclamations from the web portal.
2026-08-18 13:38:05 -05:00
NeilClack 86b6720676 Move to pure git clone
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 1m23s
Build and Push to Digital Ocean Container Registry / migrate (push) Canceled after 0s
2026-08-17 21:32:37 -05:00
NeilClack 94c9877eab Update action URLs to Github
Build and Push to Digital Ocean Container Registry / build-and-push (push) Failing after 36s
Build and Push to Digital Ocean Container Registry / migrate (push) Skipped
2026-08-17 21:29:42 -05:00
NeilClack 0753cfaa56 Remove v2API key
Build and Push to Digital Ocean Container Registry / build-and-push (push) Failing after 2s
Build and Push to Digital Ocean Container Registry / migrate (push) Skipped
2026-08-17 21:08:50 -05:00
NeilClack 7211beef69 Changed action runner to label 2026-08-07 14:09:42 -05:00
NeilClack dea5cd291a Update runner to runner name 2026-08-07 14:09:11 -05:00
NeilClack 64f7070ad7 Build API v2 2026-08-07 14:06:20 -05:00
NeilClack b2091deae0 update package.json 2026-07-19 08:45:01 -05:00
NeilClack 74896234f9 Add API_KEYS_ACTIVE toggle to API key middleware 2026-07-19 08:44:10 -05:00
NeilClack cca26c0578 Merge main into dev 2026-07-18 15:51:43 -05:00
NeilClack 23e232d5ea fix: license key not showing in email when created via admin page
Problem:
The admin createKey action had two issues preventing the license key from
appearing in the delivery email:

1. Wrong variable name: passed 'licenseKey' (camelCase) but the Resend
   template expects 'license_key' (snake_case).

2. Wrong template: used the default RESEND_TEMPLATE_ID (a generic
   template) instead of the 'license-key-delivery' template that the
   automation endpoint uses for license key emails.

Solution:
- Changed template variable from 'licenseKey' to 'license_key'
- Added explicit templateId: 'license-key-delivery' to match the
  automation endpoint (api/v1/automations/create/account)
- Updated email subject to 'Your Rhemawave License Key' for consistency

Tests:
Added page.server.spec.ts for the admin license-keys createKey action
covering: unauthenticated rejection, missing email/level validation,
user-not-found error, successful key creation with correct template ID
and license_key variable, fallback to user.name when profile is missing,
and graceful handling when email sending fails.
2026-07-18 15:46:41 -05:00
NeilClack b746d89448 Fix non-serializable accessFilter in admin load, add error boundary and tests 2026-07-17 14:52:09 -05:00
NeilClack dd8c194de2 Remove broken COPY command 2026-07-17 13:28:40 -05:00
253 changed files with 47649 additions and 33197 deletions
+5 -1
View File
@@ -5,4 +5,8 @@ GOOGLE_CLIENT_ID=6
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
RESEND_API_KEY= RESEND_API_KEY=
EMAIL_FROM= EMAIL_FROM=
RESEND_TEMPLATE_ID= # Optional. Resend template for the admin "set your password" invite email.
# Defaults to `registration-email`. Expected variables:
# `target_email_address`, `registration_link`.
RESEND_REGISTRATION_TEMPLATE_ID=
API_V2_ONBOARDING_SECRET=
+33 -3
View File
@@ -6,11 +6,15 @@ on:
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: orion-lxc
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
- name: Build image - name: Build image
run: | run: |
@@ -20,7 +24,7 @@ jobs:
. .
- name: Login to DigitalOcean Registry - name: Login to DigitalOcean Registry
uses: docker/login-action@v3 uses: https://github.com/docker/login-action@v3
with: with:
registry: registry.digitalocean.com registry: registry.digitalocean.com
username: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} username: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
@@ -30,3 +34,29 @@ jobs:
run: | run: |
docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }} docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }}
docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest
migrate:
needs: build-and-push
runs-on: orion-lxc
steps:
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
- name: Setup Node
uses: https://github.com/actions/setup-node@v4
with:
node-version: 24
- name: Install dependencies
run: npm ci
- name: Run database migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NODE_EXTRA_CA_CERTS: ./digital-ocean.crt
run: npm run deploy
+3
View File
@@ -7,3 +7,6 @@ bun.lockb
# Miscellaneous # Miscellaneous
/static/ /static/
# Generated code
/src/lib/generated/
-27
View File
@@ -1,27 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Backend (server-side code)",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"autoAttachChildProcesses": true
},
{
"name": "Frontend (client-side code)",
"type": "chrome",
"request": "launch",
"preLaunchTask": "Start Dev Server",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"vite:///src/*": "${webRoot}/src/*"
}
}
]
}
-1
View File
@@ -1 +0,0 @@
{}
-26
View File
@@ -1,26 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Dev Server",
"type": "shell",
"command": "npm run dev -- --port 5173 --strictPort",
"isBackground": true,
"problemMatcher": {
"pattern": [
{
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^.*VITE.*v",
"endsPattern": "^.*localhost:.*$"
}
}
}
]
}
+30 -33
View File
@@ -9,11 +9,11 @@ AGENTS.md
at your project root: at your project root:
````text ````text
# Rhemawave Portal AI Guide # Radiant Wave Portal AI Guide
## Mission ## 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. Act as a senior engineer making complete, production-ready changes.
@@ -97,9 +97,9 @@ Use narrower commands when appropriate.
If validation fails: If validation fails:
* Diagnose the failure. - Diagnose the failure.
* Fix the issue. - Fix the issue.
* Run validation again. - Run validation again.
Never finish with known failures. Never finish with known failures.
@@ -109,19 +109,19 @@ Never finish with known failures.
Application: Application:
portal.Rhemawavetech.com portal.radiantwavetech.com
Stack: Stack:
* SvelteKit 2 - SvelteKit 2
* Svelte 5 - Svelte 5
* TypeScript - TypeScript
* Tailwind CSS v4 - Tailwind CSS v4
* Prisma 7 - Prisma 7
* PostgreSQL - PostgreSQL
* Better Auth - Better Auth
* Vitest - Vitest
* Playwright - Playwright
Package manager: Package manager:
@@ -266,9 +266,9 @@ AdminPage.svelte
Registry metadata exists for: Registry metadata exists for:
* columns - columns
* searches - searches
* relations - relations
Forms are manually implemented per route. Forms are manually implemented per route.
@@ -309,8 +309,8 @@ Only load that file when modifying Svelte components or Svelte logic.
Testing uses: Testing uses:
* Vitest - Vitest
* Playwright browser testing - Playwright browser testing
For testing patterns, read: For testing patterns, read:
@@ -338,10 +338,10 @@ Only load that file when working on API functionality.
Prettier rules: Prettier rules:
* tabs - tabs
* single quotes - single quotes
* no trailing commas - no trailing commas
* 80 character print width - 80 character print width
Follow existing formatting. Follow existing formatting.
@@ -391,17 +391,14 @@ Confirm:
2. Relevant tests/checks were run. 2. Relevant tests/checks were run.
3. Failures were fixed. 3. Failures were fixed.
4. The final response explains: 4. The final response explains:
- what changed
* what changed - files modified
* files modified - verification performed
* verification performed
Do not stop at: Do not stop at:
* "I found the issue" - "I found the issue"
* "Here is a plan" - "Here is a plan"
* "The next step would be" - "The next step would be"
Finish the work. Finish the work.
+2 -2
View File
@@ -1,4 +1,4 @@
# portal.Rhemawavetech.com # portal.radiantwavetech.com
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth. 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) | | Template ID | Variables | Subject (API-level) |
| ------------------------------ | --------------------------------------------------------------- | ------------------------------ | | ------------------------------ | --------------------------------------------------------------- | ------------------------------ |
| `automated-user-registration` | `firstName`, `tempPassword`, `verifyUrl`, `loginUrl` | "Your account is ready" | | `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" | | `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. Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal `{{{varName}}}` text.
-1
View File
@@ -48,7 +48,6 @@ ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/digital-ocean.crt
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=build /app/src/lib/generated/prisma ./src/lib/generated/prisma COPY --from=build /app/src/lib/generated/prisma ./src/lib/generated/prisma
COPY --from=build /app/prisma ./prisma COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts COPY --from=build /app/prisma.config.ts ./prisma.config.ts
+14 -15
View File
@@ -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, 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. and an admin-facing platform management application. An all-in-one management platform for Radiant Wave and Radiant Wave Technologies.
@@ -36,18 +36,17 @@ podman push registry.digitalocean.com/radiantwave-portal-container-registry/port
## Admin ## Admin
Admin CRUD pages at `/admin`. 9 models have per-page routes with modals for create/edit: Admin pages live under `/admin` (requires `roleId <= 3`); `/admin` redirects to
`/admin/profiles`. Each route owns its own form and modal logic, built on the shared
`DataTable.svelte` and `AdminModal.svelte` components. The generic `$lib/svelte-admin`
CRUD layer has been removed; see `docs/ADMIN_OVERHAUL.md` for the migration notes.
| Included | Excluded | | Route | Notes |
| ------------------------ | --------------------------------- | | -------------------------------- | ------------------------ |
| users | Session (Better Auth) | | `/admin/users` | Name, Email, Role |
| profiles | Account (Better Auth) | | `/admin/profiles` | Reference implementation |
| addresses | Verification (Better Auth) | | `/admin/proclamations` | Super-only content edits |
| user-roles | UserProclamations (composite key) | | `/admin/solfeggio-frequencies` | |
| proclamations | | | `/admin/proclamation-categories` | Draggable reorder |
| solfeggio-frequencies | | | `/admin/proclamation-groups` | Draggable reorder |
| proclamation-categories | | | `/admin/license-keys` | List-only + rotate |
| license-keys (list-only) | |
| subscription-levels | |
See `src/lib/svelte-admin/README.md` for architecture and configuration docs.
+20 -2
View File
@@ -97,6 +97,26 @@ Requires API key.
--- ---
# Schedule (v2)
Endpoint:
GET /api/v2/schedule?email=<email>&licenseKey=<key>
Authenticates with the paired `email` + `licenseKey` query parameters and
validates only that the key belongs to the account (not that the license is
active/entitled). Returns the user's Blocks with start/end times and the IDs of
the selected Proclamations (content is fetched separately via
`/api/v2/proclamations`). Each block also carries `dynamicAudio`; when true, the
kiosk enables dynamic audio for the block's duration, overriding any audio
selections made on the device while the block is active. Overnight blocks (end
clock time before start) are supported and flagged with `overnight: true` plus a
wrapping `durationMinutes`. Rate limited to 10 requests per minute per IP.
Failure messages follow the existing v2 shape:
`not-authorized` or `this key doesn't appear to belong to you`.
---
# GHL Account Creation Webhook # GHL Account Creation Webhook
Endpoint: Endpoint:
@@ -153,7 +173,6 @@ Variables:
- verifyUrl - verifyUrl
- loginUrl - loginUrl
license-key-delivery license-key-delivery
Variables: Variables:
@@ -161,7 +180,6 @@ Variables:
- firstName - firstName
- license_key - license_key
email-verification-email email-verification-email
Variables: Variables:
+1
View File
@@ -116,6 +116,7 @@ This can fail to update correctly.
Use: Use:
<select onchange={(e) => frequency = e.currentTarget.value}> <select onchange={(e) => frequency = e.currentTarget.value}>
<option value="1" selected={frequency === '1'}> <option value="1" selected={frequency === '1'}>
One One
</option> </option>
+4 -4
View File
@@ -30,13 +30,13 @@ Tests are colocated with source files.
Client tests: Client tests:
*.svelte.test.ts _.svelte.test.ts
*.svelte.spec.ts _.svelte.spec.ts
Server tests: Server tests:
*.test.ts _.test.ts
*.spec.ts _.spec.ts
--- ---
+59
View File
@@ -0,0 +1,59 @@
{
"version": "sched-adjacent-0001",
"type": "daily",
"daily": [
{
"id": "dd111111-1111-4111-8111-111111111111",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
},
{
"id": "dd222222-2222-4222-8222-222222222222",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "08:30",
"endMinutes": 510,
"overnight": false,
"durationMinutes": 90,
"proclamationIds": [
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b",
"33333333-3333-4333-8333-333333333333"
],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "dd111111-1111-4111-8111-111111111111",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"]
},
{
"id": "dd222222-2222-4222-8222-222222222222",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "08:30",
"endMinutes": 510,
"overnight": false,
"durationMinutes": 90,
"dynamicAudio": false,
"proclamationIds": [
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b",
"33333333-3333-4333-8333-333333333333"
]
}
]
}
+59
View File
@@ -0,0 +1,59 @@
{
"version": "sched-daily-0001",
"type": "daily",
"daily": [
{
"id": "11111111-1111-4111-8111-111111111111",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "09:00",
"endMinutes": 540,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
],
"dynamicAudio": true
},
{
"id": "22222222-2222-4222-8222-222222222222",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "11111111-1111-4111-8111-111111111111",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "09:00",
"endMinutes": 540,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
]
},
{
"id": "22222222-2222-4222-8222-222222222222",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
}
]
}
+53
View File
@@ -0,0 +1,53 @@
{
"version": "sched-dynamic-0001",
"type": "daily",
"daily": [
{
"id": "ee111111-1111-4111-8111-111111111111",
"startTime": "08:00",
"startMinutes": 480,
"endTime": "10:00",
"endMinutes": 600,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
},
{
"id": "ee222222-2222-4222-8222-222222222222",
"startTime": "20:00",
"startMinutes": 1200,
"endTime": "22:00",
"endMinutes": 1320,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "ee111111-1111-4111-8111-111111111111",
"startTime": "08:00",
"startMinutes": 480,
"endTime": "10:00",
"endMinutes": 600,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"]
},
{
"id": "ee222222-2222-4222-8222-222222222222",
"startTime": "20:00",
"startMinutes": 1200,
"endTime": "22:00",
"endMinutes": 1320,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": false,
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"]
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"email": "jane@example.com",
"blocks": [
{
"id": "11111111-1111-4111-8111-111111111111",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "09:00",
"endMinutes": 540,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
]
},
{
"id": "22222222-2222-4222-8222-222222222222",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
}
]
}
@@ -0,0 +1,59 @@
{
"version": "sched-midnight-0001",
"type": "daily",
"daily": [
{
"id": "cc111111-1111-4111-8111-111111111111",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
],
"dynamicAudio": true
},
{
"id": "cc222222-2222-4222-8222-222222222222",
"startTime": "09:00",
"startMinutes": 540,
"endTime": "21:00",
"endMinutes": 1260,
"overnight": false,
"durationMinutes": 720,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "cc111111-1111-4111-8111-111111111111",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"dynamicAudio": true,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
]
},
{
"id": "cc222222-2222-4222-8222-222222222222",
"startTime": "09:00",
"startMinutes": 540,
"endTime": "21:00",
"endMinutes": 1260,
"overnight": false,
"durationMinutes": 720,
"dynamicAudio": false,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"version": "sched-none-0001",
"type": null,
"daily": [],
"weekly": {},
"blocks": []
}
+53
View File
@@ -0,0 +1,53 @@
{
"version": "7f53efde17d291a1ecf604e266d3803d1495b986",
"type": "daily",
"daily": [
{
"id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
},
{
"id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
}
],
"weekly": {},
"blocks": [
{
"id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
},
{
"id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
}
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": "6de852ba72ce1ab3052150640730ee87a7d460b5",
"type": "weekly",
"daily": [],
"weekly": {
"mon": [
{
"id": "a1054b78-480a-4e87-8465-aac8d677c666",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
},
{
"id": "25979ec9-f7da-4aa4-864f-f74b1a2da78b",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "08:30",
"endMinutes": 510,
"overnight": false,
"durationMinutes": 90,
"dynamicAudio": true,
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
}
],
"tue": [],
"wed": [],
"thu": [],
"fri": [],
"sat": [],
"sun": [
{
"id": "e2237d98-834c-433b-8979-b56ddad2ef7a",
"startTime": "23:00",
"startMinutes": 1380,
"endTime": "01:00",
"endMinutes": 60,
"overnight": true,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": ["fcd5a291-3d6e-4678-9d85-9b550e80f1c7"]
}
]
},
"blocks": []
}
+63
View File
@@ -0,0 +1,63 @@
{
"version": "sched-weekly-0001",
"type": "weekly",
"daily": [],
"weekly": {
"mon": [
{
"id": "a1111111-1111-4111-8111-111111111111",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "08:00",
"endMinutes": 480,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
],
"tue": [],
"wed": [
{
"id": "a2222222-2222-4222-8222-222222222222",
"startTime": "12:00",
"startMinutes": 720,
"endTime": "13:00",
"endMinutes": 780,
"overnight": false,
"durationMinutes": 60,
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"],
"dynamicAudio": true
}
],
"thu": [],
"fri": [
{
"id": "a3333333-3333-4333-8333-333333333333",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "22:00",
"endMinutes": 1320,
"overnight": false,
"durationMinutes": 240,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
"dynamicAudio": false
}
],
"sat": [],
"sun": [
{
"id": "a4444444-4444-4444-8444-444444444444",
"startTime": "23:00",
"startMinutes": 1380,
"endTime": "01:00",
"endMinutes": 60,
"overnight": true,
"durationMinutes": 120,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
]
},
"blocks": []
}
+802
View File
@@ -0,0 +1,802 @@
openapi: 3.0.3
info:
title: Radiant Wave Portal API (v2)
version: 2.0.0
description: |
Read API used by the Radiant Wave kiosk plus the automation onboarding
webhook.
## Authentication
The v2 API is **not** API-key gated. The former bearer-token middleware in
`src/hooks.server.ts` is present but disabled, so v2 routes are served
without a global API key.
Two endpoints carry their own credential semantics:
- `GET /api/v2/proclamations` optionally accepts the paired
`x-license-key` / `x-email` headers to scope results to an entitled user.
Supplying neither returns the full catalogue; supplying only one, or a
pair that does not match, is rejected.
- `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256
request signature (see the operation description).
servers:
- url: https://portal.radiantwavetech.com
description: Production
- url: http://localhost:5173
description: Local development
tags:
- name: Catalogue
description: Reference data for categories, groups, frequencies and palettes.
- name: Proclamations
description: Proclamation content, optionally scoped by license entitlement.
- name: License
description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations
description: Signed machine-to-machine webhooks.
paths:
/api/v2/categories:
get:
tags: [Catalogue]
summary: List proclamation categories
operationId: listCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/proclamation-categories:
get:
tags: [Catalogue]
summary: List proclamation categories (dedicated alias)
description: Alias of `/api/v2/categories`; returns the identical payload.
operationId: listProclamationCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/groups:
get:
tags: [Catalogue]
summary: List proclamation groups
description: Groups are ordered by `sortOrder` ascending.
operationId: listGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/proclamation-groups:
get:
tags: [Catalogue]
summary: List proclamation groups (dedicated alias)
description: Alias of `/api/v2/groups`; returns the identical payload.
operationId: listProclamationGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/frequencies:
get:
tags: [Catalogue]
summary: List canonical solfeggio frequencies
description: |
Returns the nine canonical frequencies (ids 1-9) the kiosk expects.
These are static values, not the portal database autoincrement ids.
operationId: listFrequencies
responses:
'200':
description: The nine canonical frequencies.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/Frequency'
/api/v2/color-palettes:
get:
tags: [Catalogue]
summary: List color palettes per canonical frequency
description: |
Returns one 7-color palette for each canonical frequency id (1-9).
Palette rows stored in the database take precedence; ids without a row
fall back to the built-in canonical palette.
operationId: listColorPalettes
responses:
'200':
description: Nine palettes, one per canonical frequency id.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/ColorPalette'
/api/v2/proclamations:
get:
tags: [Proclamations]
summary: List proclamations
description: |
Without entitlement headers, returns the full proclamation catalogue.
With both `x-license-key` and `x-email`, returns only the proclamations
assigned to the matching user. Both headers must be present together and
the key must belong to the user.
Proclamations whose frequency has no canonical id (for example 936 Hz)
are omitted from the response.
operationId: listProclamations
parameters:
- in: header
name: x-license-key
schema:
type: string
required: false
description: License key. Must be supplied together with `x-email`.
- in: header
name: x-email
schema:
type: string
format: email
required: false
description: Account email. Must be supplied together with `x-license-key`.
responses:
'200':
description: Proclamation records with canonical frequency ids and embedded palettes.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Proclamation'
'400':
description: |
Missing entitlement or the supplied key/email pair is invalid.
Message is `not-authorized`, or
`this key doesn't appear to belong to you` when the key does not
belong to the specified user.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/license:
post:
tags: [License]
summary: Validate a license key
description: |
Validates that the license key exists, is active, and belongs to the
account matching `email_address`. A successful validation records
`lastValidatedAt` on the key.
operationId: validateLicense
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseRequest'
responses:
'200':
description: The validated key and its owner email.
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseResponse'
'400':
description: |
Validation failure or malformed body. Possible messages:
`content-type must be application/json`, `invalid JSON body`,
`request body is required`, `license_key is required`,
`email_address is required`, `license key not found`,
`license key is not active`, `user does not exist`,
`key does not belong to specified user`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/schedule:
get:
tags: [Schedule]
summary: Get a user's listening schedule (query-param auth)
description: |
Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via
the paired `email` + `licenseKey` **query parameters** and validates only
that the key belongs to the account for that email. License
active/entitlement is deliberately not checked here: an invalid license
would already lock the kiosk out, so this endpoint only checks the
pairing.
Returns every Block with its start/end times and the ordered list of
selected Proclamation IDs (the legacy daily flattening in `blocks`), and
is extended additively with `version`, `type`, `daily`, and `weekly`.
Overnight blocks (an end clock time before the start) are supported and
flagged with `overnight: true` and a wrapping `durationMinutes`. Each
block also carries `dynamicAudio`; when true, the kiosk enables dynamic
audio for the block's duration, overriding any audio selections made on
the device. Proclamation content is not included; the kiosk fetches it
separately via `/api/v2/proclamations`. For `type: "weekly"`, `blocks` is
an empty array (the server cannot flatten weekly in the kiosk's
timezone). This endpoint emits no ETag and never returns 304.
operationId: getSchedule
parameters:
- in: query
name: email
required: true
schema:
type: string
format: email
description: Account email.
- in: query
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
description: License key. The legacy alias `key` is also accepted.
responses:
'200':
description: The user's schedule (possibly empty).
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduleResponse'
example:
email: jane@example.com
blocks:
- id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f
startTime: '21:00'
startMinutes: 1260
endTime: '09:00'
endMinutes: 540
overnight: true
durationMinutes: 720
dynamicAudio: true
proclamationIds:
- b40fdcb9-703c-420e-af32-a081964c6f08
- 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b
'400':
description: |
Missing/invalid parameters, unknown key or user, or the key does not
belong to the email's account. Possible messages: `not-authorized`,
`this key doesn't appear to belong to you`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/licenses/{licenseKey}/schedule:
get:
tags: [Schedule]
summary: Get a License Key's schedule (Kiosk-facing, conditional)
description: |
Kiosk-facing, License-Key-only schedule read. Returns the target payload
(`version`, `type`, `daily`, `weekly`, `blocks`). `version` is a
content-derived opaque string and is also the `ETag`; a matching
`If-None-Match` returns `304`. An unknown License Key is `404`; a key
with no Schedule returns `type: null` with empty lists. No
authentication (see the API description) beyond the 10/min/IP limit.
operationId: getLicenseSchedule
parameters:
- in: path
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
- in: header
name: If-None-Match
required: false
schema:
type: string
description: Prior ETag; when it matches, returns 304.
responses:
'200':
description: The target schedule payload.
headers:
ETag:
schema:
type: string
description: The quoted `version`.
content:
application/json:
schema:
$ref: '#/components/schemas/SchedulePayload'
'304':
description: Not modified; the caller keeps its cached payload.
headers:
ETag:
schema:
type: string
'404':
description: 'Unknown license (`unknown_license`).'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/licenses/{licenseKey}/system-name:
put:
tags: [License]
summary: Set a License Key's System Name (Kiosk-only)
description: |
Sets the human-readable System Name for a License Key. The Kiosk is the
only writer; the Portal displays the name read-only. The name is unique
per account, compared case-insensitively, when non-null. Idempotent:
re-sending the current name returns 200 without a write.
operationId: setSystemName
parameters:
- in: path
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [systemName]
properties:
systemName:
type: string
minLength: 1
maxLength: 40
responses:
'200':
description: The stored name.
content:
application/json:
schema:
type: object
required: [systemName, systemNameStatus]
properties:
systemName:
type: string
systemNameStatus:
type: string
enum: [ok]
'400':
description: 'Invalid name (`invalid_name`); empty/whitespace is invalid.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'404':
description: 'Unknown license (`unknown_license`).'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'409':
description: 'Name already used on the account (`name_taken`).'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/automations/create/account:
post:
tags: [Automations]
summary: Create an account and license key (signed webhook)
description: |
Machine-to-machine onboarding webhook. The request must be signed with
HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`.
The canonical string that is signed is the newline-joined concatenation
of:
```
POST
<path>
<x-timestamp>
<x-nonce>
<raw request body>
```
where `<path>` is `/api/v2/automations/create/account`. The signature is
the lowercase hex digest.
On success, creates the user if they do not already exist, generates a
license key, emails it, and returns the user id. Invalid or missing
signatures return `401 { "error": "invalid signature" }` (the same
response is used for every signature failure, including replay and
timestamp skew).
operationId: createAutomationAccount
parameters:
- in: header
name: x-timestamp
required: true
schema:
type: string
description: Unix epoch seconds. Must be within 300 seconds of the server clock.
- in: header
name: x-nonce
required: true
schema:
type: string
description: Unique value per request. Replays within the skew window are rejected.
- in: header
name: x-signature
required: true
schema:
type: string
pattern: '^[0-9a-f]{64}$'
description: Lowercase hex HMAC-SHA256 of the canonical request string.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingRequest'
responses:
'200':
description: Account and license key created.
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingResponse'
'400':
description: |
Malformed or invalid request. Possible messages:
`request body is required`, `invalid JSON body`, or
`email is required`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'401':
description: Signature missing, malformed, expired, replayed, or incorrect.
content:
application/json:
schema:
type: object
required: [error]
properties:
error:
type: string
example: invalid signature
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: |
Rate limit exceeded. Limits are 5 requests per minute per IP and
3 requests per 24 hours per email address.
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'500':
description: The account could not be created.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
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
Frequency:
type: object
required: [id, name, value]
properties:
id:
type: integer
minimum: 1
maximum: 9
description: Canonical id used by the kiosk.
name:
type: string
value:
type: integer
description: Frequency in Hz.
ColorPalette:
type: object
required: [solfeggioFrequencyId, colors]
properties:
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colors:
type: array
description: Seven hex colors, ordered light to dark.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
Proclamation:
type: object
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
properties:
id:
type: string
format: uuid
name:
type: string
content:
type: string
description: HTML content.
categoryId:
type: integer
groupId:
type: integer
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colorPalette:
type: array
description: Present only when a palette exists for the canonical frequency id.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
LicenseRequest:
type: object
required: [license_key, email_address]
properties:
license_key:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
example: ABCD-EFGH-IJKL-MNOP
email_address:
type: string
format: email
maxLength: 254
systemName:
type: string
minLength: 1
maxLength: 40
description: |
Optional. The Kiosk's System Name (trimmed). Omitted means the stored
name is left untouched; empty/whitespace is invalid, never a clear.
LicenseResponse:
type: object
required: [key, email_address, systemNameStatus]
properties:
key:
type: string
example: ABCD-EFGH-IJKL-MNOP
email_address:
type: string
format: email
systemNameStatus:
type: string
enum: [ok, unchanged, conflict, invalid]
description: |
Advisory outcome of the optional `systemName`. Validation itself
always succeeds. `unchanged` is returned when the field is omitted.
ScheduleBlock:
type: object
required:
[
id,
startTime,
startMinutes,
endTime,
endMinutes,
overnight,
durationMinutes,
dynamicAudio,
proclamationIds
]
properties:
id:
type: string
format: uuid
startTime:
type: string
example: '06:00'
description: Wall-clock start time as `HH:MM`.
startMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
endTime:
type: string
example: '08:00'
description: Wall-clock end time as `HH:MM`.
endMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
overnight:
type: boolean
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
durationMinutes:
type: integer
minimum: 1
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
dynamicAudio:
type: boolean
description: |
When true, dynamic audio is enabled for the block's duration, overriding any
audio selections made on the device while the block is active.
proclamationIds:
type: array
description: IDs of the selected Proclamations, in play order.
items:
type: string
format: uuid
ScheduleResponse:
type: object
required: [email, blocks]
properties:
email:
type: string
format: email
version:
type: string
type:
type: string
nullable: true
enum: [daily, weekly, null]
daily:
type: array
items:
$ref: '#/components/schemas/ScheduleBlock'
weekly:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/ScheduleBlock'
blocks:
type: array
items:
$ref: '#/components/schemas/ScheduleBlock'
SchedulePayload:
type: object
required: [version, type, daily, weekly, blocks]
properties:
version:
type: string
description: Opaque content hash; doubles as the ETag.
type:
type: string
nullable: true
enum: [daily, weekly, null]
daily:
type: array
items:
$ref: '#/components/schemas/ScheduleBlock'
weekly:
type: object
description: Present with all seven keys when `type` is weekly.
additionalProperties:
type: array
items:
$ref: '#/components/schemas/ScheduleBlock'
blocks:
type: array
description: Legacy daily flattening; empty for weekly.
items:
$ref: '#/components/schemas/ScheduleBlock'
OnboardingRequest:
type: object
required: [email]
properties:
email:
type: string
format: email
maxLength: 254
name:
type: string
maxLength: 120
description: Optional display name. Defaults to the email local-part.
OnboardingResponse:
type: object
required: [success, message, user_id]
properties:
success:
type: boolean
message:
type: string
example: Account and license key created
user_id:
type: string
ErrorMessage:
type: object
required: [message]
properties:
message:
type: string
+619
View File
@@ -0,0 +1,619 @@
# Schedule API & Data Contract
Status: Phase 0 (contract only — no application code). This document is the
authoritative API/data contract for the multi-phase scheduler overhaul. It is
kept **byte-identical** in both repositories:
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/schedule-api-contract.md`
- `portal` (SvelteKit/Node) — `docs/schedule-api-contract.md`
Any change is applied to both copies in the same step, with a dated line
appended to the [Changelog](#changelog).
Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule,
License Key, System Name) are used with the meanings defined in the scheduler
overhaul brief.
---
## Current state
This section reflects the code as it exists at the start of Phase 0. It is
descriptive, not aspirational; the target behaviour is defined in the later
sections.
### Kiosk (`radiantwave`)
- **License validation.** `POST /api/v2/license` with body
`{"license_key": "...", "email_address": "..."}`. Implemented in
`src/network/api_client.cpp` (`validateLicense`, `parseLicenseJson`) and
consumed by `src/network/license_service.cpp` (`validateStoredLicense`,
`markLicenseVerified`, `isLicenseWithinGracePeriod`). A 2xx response is the
device's "subscribed" flag; a 7-day offline grace window is stored as
`license_last_verified` in `system_kv` (`src/db/db_keys.h`). Response body
currently read: `{key, email_address}`; additional fields are ignored.
- **Schedule fetch.** `GET /api/v2/schedule?email=<email>&licenseKey=<key>`
(`src/network/api_client.cpp`: `fetchSchedule`, `parseScheduleJson`;
`PATH_SCHEDULE = "/schedule"`). Response shape read by the Kiosk:
`{email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes,
dynamicAudio, proclamationIds}]}`. `dynamicAudio` is optional on the wire and
defaults to `true` in `parseScheduleJson`. Missing/blank credentials cause an
early `false`.
- **Schedule persistence.** SQLite tables `schedule_blocks` and
`schedule_block_proclamations` (`src/db/database_manager.cpp:185`,
`replaceScheduleBlocks` at `:1105`, `getScheduleBlocks` at `:1169`). Full
replace; block `sort_order` and per-block proclamation `position` preserved.
No day-of-week column and no schedule `type`; the model is implicitly daily.
- **Schedule sync.** `syncScheduleFromApi` (`src/network/proclamation_sync.cpp:172`)
is invoked only as a **best-effort** side effect of
`syncProclamationsFromApi` (`:168`). There is no boot sync, no network-up
sync, no periodic timer, and no conditional GET (`If-None-Match`/ETag).
`syncProclamationsFromApi` is called from `Home::syncProclamationsIfEmpty`
(`src/home.cpp:461`) and `ProclamationOptionsPage::onPush`
(`src/proclamation_options_page.cpp:49`). There is no explicit atomic
schedule cache file; the SQLite database is the durable store.
- **Schedule playback.** `src/scroller_page.cpp` selects the active block by
comparing `timezone::currentLocalMinutes(cfg.timezone)`
(`src/timezone_helper.h`) against each block via
`scroller::minuteInScheduleBlock` (`src/scroller_streams.h:31`). Block
selection is daily-only (no day-of-week). An active block's ordered
`proclamation_ids` replace the local selection
(`src/scroller_streams.cpp:57`). `effectiveDynamicAudio()`
(`src/scroller_page.cpp:302`) forces dynamic audio when the active block has
`dynamic_audio == true`, otherwise the device-wide
`config.audio.dynamic_audio` applies. A block change is latched in
`scheduleChangePending_` and applied at the next proclamation boundary.
- **Settings persistence.** `~/.local/radiantwave/config.yaml` (YAML) via
`ConfigManager` (`src/config/config_manager.cpp`, `src/config/yaml_parser.cpp`,
schema in `src/config/app_config.h`): `timezone`, `audio.dynamic_audio`,
`proclamation.schedule_active` (Kiosk-local on/off toggle, default `false`).
Identity lives in SQLite `system_kv` (`email_address`, `license_key`,
`license_last_verified`). There is no System Name anywhere.
- **Conventions.** `API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"`
(`src/network/api_client.h`). Kiosk API knowledge is mirrored in
`openapi.yaml` at the repo root.
### Portal (`portal`)
- **Framework/tooling.** SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4;
Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate
(`prisma/schema.prisma`, `prisma/migrations/`, `npm run migrate`,
`npm run deploy`; config in `prisma.config.ts`). API routes under
`src/routes/api/v1/` (API-key gated) and `src/routes/api/v2/`
(unauthenticated). v2 helpers: `src/lib/server/v2-helpers.ts`
(`jsonOk`, `jsonError`, `readJsonBody`, `rateLimited`, `clientIp`); errors
are `{ "message": "<text>" }`.
- **License validation.** `POST /api/v2/license`
(`src/routes/api/v2/license/+server.ts`): validates `license_key` +
`email_address`, requires the key be active and owned by the account, stamps
`LicenseKey.lastValidatedAt`, returns `serializeLicense({key, emailAddress})`
(`src/lib/server/v2-serializers.ts`). A v1 variant exists
(`src/routes/api/v1/license/+server.ts`).
- **Schedule storage.** `Schedule` is **per user** (`userId String @unique`,
`prisma/schema.prisma:263`), daily-only, `0..4` `Block`s. `Block`
(`:275`) stores `startMinutes`/`endMinutes`, `dynamicAudio Boolean
@default(false)`, and ordered `BlockProclamations` (`sortOrder`). The schema
comment states overnight ranges are disallowed in v1, yet the serializer and
Kiosk both support `overnight` — a known inconsistency.
- **Schedule service/validation.** `src/lib/server/schedule.ts` (DB
orchestration: `getOrCreateSchedule`, `listBlocks`, `createBlock`,
`updateBlock`, `deleteBlock`) and the isomorphic
`src/lib/schedule-utils.ts` (`MAX_BLOCKS = 4`, `SNAP_MINUTES = 15`,
`validateBlock`, `findOverlap`, `isOvernight`, `blockDuration`,
`blockSegments`, `formatTime`, `toHm`, `snapMinutes`). Validation enforces a
4-block maximum on create, `startMinutes !== endMinutes`, at least one
Proclamation, and no overlaps (overnight-aware via linear segments).
- **Schedule read API.** `GET /api/v2/schedule`
(`src/routes/api/v2/schedule/+server.ts`): auth by paired `email` +
`licenseKey` (legacy alias `key`) query params; checks only that the key
belongs to the email's account (entitlement deliberately not checked); rate
limited 10/min/IP; returns `{email, blocks: [serializeScheduleBlock(...)]}`
where `serializeScheduleBlock` (`src/lib/server/v2-serializers.ts:165`) emits
`{id, startTime, startMinutes, endTime, endMinutes, overnight,
durationMinutes, dynamicAudio, proclamationIds}`.
- **Schedule UI.** Protected user route
`src/routes/(protected)/(user)/schedule/` (`+page.server.ts`,
`+page.svelte`) with `src/lib/components/schedule/DaySchedule.svelte` (single
24h day timeline, drag-to-create) and `BlockModal.svelte`. Dashboard
(`(protected)/(user)/dashboard/`) lists the user's License Keys. No System
Name is displayed or editable.
### Gaps between current state and target
- Schedule is per-user, not per-License-Key.
- No `daily`/`weekly` typing; no day-of-week storage or UI.
- No `version`/ETag, no conditional GET.
- Schedule GET requires `email` + `licenseKey`; there is no license-key-only
route, no `/api/v2/licenses/...` namespace, and no system-name route.
- `dynamicAudio` DB default is `false`; the target default for new Blocks is
`true`.
- No System Name field, validation, or display.
- Sync is opportunistic with no boot/network-up/5-minute cadence and no
atomic cache file.
- Portal stores no timezone (matches target) and shows no informational
geoip-derived timezone.
---
## Time semantics
- Times are **minute precision**, stored as 24-hour `"HH:MM"` strings
representing **local wall-clock** time. AM/PM is display-only.
- Numerators may also be carried as integer minutes-from-midnight
(`startMinutes`/`endMinutes`, `0..1440`) for the Kiosk; the string form is
canonical on the wire.
- A Block is the **half-open interval `[start, end)`**.
- A Block may start on the same minute the previous Block ends (`end 07:00`,
next `start 07:00` is valid and has no gap).
- A Block whose `end` is earlier than its `start` **crosses midnight** and
belongs to its start day. In weekly mode, Sunday's crossing Block spills into
Monday. `start == end` is **invalid** (zero- or full-length ambiguity).
- **Overlaps are invalid**, including spillover from a midnight-crossing Block
into the next day's Blocks (or, in daily mode, into the same list's early
Blocks). Touching boundaries are **not** overlaps.
- Gaps between Blocks are allowed. During a gap the Kiosk plays whatever
Proclamations are selected locally on the Kiosk.
- The Kiosk evaluates Schedules in its own Timezone setting
(`config.timezone`, IANA code). **The payload carries no timezone.** The
Portal may show the user's geoip-derived timezone informationally only.
---
## Schedule data model (JSON)
Each License Key has **at most one** Schedule. `type` is `"daily"` or
`"weekly"` and determines the mode; there is no separate global mode. **No**
Schedule means `type` is `null`.
Suggested shape from the brief, adapted to existing field names (`start`/`end`
→ `startTime`/`endTime`, retaining the existing `startMinutes`/`endMinutes`
integers for the Kiosk):
```json
{
"version": "<opaque string; also used as the ETag>",
"type": "daily" | "weekly" | null,
"daily": [ Block, ... ],
"weekly": { "mon": [Block], "tue": [Block], "wed": [Block],
"thu": [Block], "fri": [Block], "sat": [Block],
"sun": [Block] },
"blocks": [ LegacyBlock, ... ]
}
```
Block:
```json
{
"id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
```
- `version` is an opaque string derived from the Schedule content (see
[Endpoints](#endpoints)). It is implementation-defined and must change
whenever the serialized schedule changes.
- **Only the fields for the active type are populated.** For `type: "daily"`,
`daily` is populated and `weekly` is omitted or empty. For `type: "weekly"`,
`weekly` is populated with all seven keys and `daily` is omitted or empty.
For `type: null`, both are empty/omitted.
- `weekly` always contains all seven lowercase keys `mon`..`sun`; a day with no
Blocks is an empty array.
- `blocks` is the **legacy flattening** retained for older Kiosks (see
[Backward compatibility](#backward-compatibility)). It uses the existing
`LegacyBlock` shape serialized by the current portal
(`{id, startTime, startMinutes, endTime, endMinutes, overnight,
durationMinutes, dynamicAudio, proclamationIds}`). For `type: "weekly"`,
`blocks` is an **empty array** (the server does not know the Kiosk's
timezone and cannot flatten weekly to a single day).
- `proclamationIds` is the ordered play list (order is significant);
duplicates are not meaningful and are de-duplicated by the Portal.
- `dynamicAudio` defaults to `true` for new Blocks created on the Portal.
- Proclamation **content** is never included; the Kiosk fetches content
separately via `/api/v2/proclamations`. Portal users only ever see
title/category/group.
---
## Validation rules
These are enforced by the Portal (authoritative) and defended against by the
Kiosk.
1. A Schedule belongs to exactly one License Key; a License Key has at most one
Schedule.
2. `type` is one of `"daily"`, `"weekly"`, or `null`.
3. A Block has `start` and `end` times; `start == end` is invalid.
4. `end < start` denotes a midnight-crossing Block belonging to its start day.
5. Blocks within a day (and the spillover of a midnight-crossing Block into the
following day) must not overlap. Touching boundaries are allowed.
6. Weekly modes apply rules 3–5 independently per day; a `sun` Block crossing
midnight must not overlap `mon` early Blocks.
7. A Block must contain at least one Proclamation. Empty Blocks are rejected
server-side. The Kiosk defensively **skips invalid Blocks** and treats them
as gaps.
8. Gaps are allowed; during a gap local Kiosk selection applies.
9. `dynamicAudio` is a boolean; absent means `true` for new Blocks.
10. Times are minute-precision `HH:MM` strings; the Portal snaps UI input to
`SNAP_MINUTES` (15) with a `MINUTE_STEP` (5) constraint from the existing
helpers.
11. The existing Portal cap of `MAX_BLOCKS = 4` per day list is **retained**
for Phase 0 (see [Open items](#open-items)).
The Kiosk must not crash on, and must not play, an invalid Block: it skips it.
---
## Endpoints
The API remains **unauthenticated** (no API keys) for v2. This is a known
limitation and is recorded under [Open items](#open-items).
All new endpoints live under the existing versioned namespace
`/api/v2/...` to preserve conventions. v1 routes are unchanged.
### (a) License validation — existing, additive
`POST /api/v2/license`
Request (existing fields unchanged; `systemName` additive and optional):
```json
{
"license_key": "ABCD-EFGH-IJKL-MNOP",
"email_address": "jane@example.com",
"systemName": "Living Room"
}
```
Response (existing fields unchanged; `systemNameStatus` additive):
```json
{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" }
```
- `systemName` is optional. When present it is trimmed; when absent the stored
name is left untouched.
- `systemNameStatus` ∈ `{"ok", "unchanged", "conflict", "invalid"}`:
- `ok` — name was set or updated.
- `unchanged` — omitted, or identical after trim/case-insensitive compare.
- `conflict` — another License Key on the same account already holds the name.
- `invalid` — trimmed length outside 1–40.
- **Validation itself succeeds regardless of name status.** A non-`ok` status
never fails the license check; the Kiosk treats it as advisory.
- Errors and rate limiting follow the existing endpoint (400 messages,
413 body cap, 429 with `Retry-After`).
### (b) Set System Name — Kiosk-facing
`PUT /api/v2/licenses/{licenseKey}/system-name`
Request:
```json
{ "systemName": "Living Room" }
```
Responses:
| Status | Condition | Body |
| --------------------- | ----------------------------------------- | ------------------------------------------------- |
| 200 | Name set/updated (or unchanged) | `{"systemName": "...", "systemNameStatus": "ok"}` |
| 400 `invalid_name` | Trimmed length outside 1–40 | `{"message": "invalid_name"}` |
| 404 `unknown_license` | No License Key matches | `{"message": "unknown_license"}` |
| 409 `name_taken` | Another key on the account holds the name | `{"message": "name_taken"}` |
- The Kiosk is the **only** writer. The Portal has no rename capability.
- The response body uses the existing v2 error convention
(`{"message": "<code>"}`).
- Rate limited at the existing 10 requests/min/IP convention.
### (c) Get Schedule — Kiosk-facing, key-only, conditional
`GET /api/v2/licenses/{licenseKey}/schedule`
- 200 with `ETag: "<version>"` and the payload from
[Schedule data model](#schedule-data-model-json). A License Key with no
Schedule returns 200 with `type: null` and empty lists.
- `304 Not Modified` when the request's `If-None-Match` matches the current
`ETag`.
- `404` (`unknown_license`) when no License Key matches.
- `version` doubles as the ETag value; the header value is the quoted form.
- The response is the **target** payload (`version`, `type`, `daily`, `weekly`,
`blocks`).
- Rate limited at the existing 10 requests/min/IP convention.
### (d) Legacy schedule read — existing, evolved additively
`GET /api/v2/schedule?email=<email>&licenseKey=<key>` remains supported with
its existing auth semantics. Its response is extended **additively** with the
same `version`, `type`, `daily`, and `weekly` fields, while continuing to
populate `email` and `blocks` exactly as before. Old Kiosks ignore the new
fields; new Kiosks may read either endpoint. This endpoint does **not** emit an
ETag and does **not** return 304.
### (e) Portal-internal endpoints (not Kiosk-facing)
These are session-authenticated Portal actions; they are **not** part of the
Kiosk API and are not served by `/api/v2/...`.
- Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on
`(protected)/(user)/schedule` (`?/setType`, `?/createBlock`, `?/updateBlock`,
`?/deleteBlock`), backed by `$lib/server/schedule.ts`.
- **Copy** — implemented in Phase 4 as the `?/copy` action plus
`copySchedule()` in `$lib/server/schedule.ts`. Copies one License Key's
Schedule to one or more other License Keys **by value, in a single database
transaction** (all targets succeed or none). The target `type` becomes the
source's; Block rows get fresh IDs; each block's `dynamicAudio` and ordered
Proclamation references are preserved. The source and every target must
belong to the caller's account; a target equal to the source, an empty
target list, or a source without a schedule are rejected. The resulting
schedule is run through the shared validator before commit. Because Block IDs
are part of the serialized payload, every target's content-derived
`version`/ETag changes, so its Kiosk refetches.
---
## Sync behavior
Target Kiosk sync (defaults; adjust only if the code forces it):
- The Kiosk pulls the Schedule:
- on boot,
- on network-up,
- every 5 minutes.
- Pulls use conditional GET (`If-None-Match` / ETag); a `304` leaves the cache
untouched.
- The last Schedule is cached to disk with an **atomic write** and used offline.
- The schedule on/off toggle is **Kiosk-local** (existing
`proclamation.schedule_active`) and is not synced.
- With no cache and no network, the Kiosk behaves as if schedules are off
(local Proclamation selection applies).
- During gaps, or when schedules are off, the local audio selection applies.
Current-state deltas (to be closed by later phases): no boot/network-up/5-minute
cadence, no ETag, no explicit atomic cache file. These are recorded under
[Touchpoints per phase](#touchpoints-per-phase).
Identity for sync is the License Key. The legacy schedule endpoint additionally
requires the paired email; the new endpoint uses the License Key alone.
---
## System Name rules
- A System Name is a human-readable label for a License Key (e.g.
"Living Room"). It is a **label only** — never used as a reference or key.
- Unique **per account**, compared **case-insensitively**.
- Trimmed, **1–40 characters**.
- Set or changed **only via the Kiosk**. The Portal displays it **read-only**
and has no rename capability.
- The Portal falls back to displaying the License Key when no name is set.
- A Kiosk that omits `systemName` never clears an existing name.
- Wire representations: request field `systemName`; status field
`systemNameStatus` ∈ `{"ok","unchanged","conflict","invalid"}` (see
[Endpoints](#endpoints)).
---
## Dynamic audio
- Each Block has a boolean `dynamicAudio`.
- The Portal defaults it to **`true` for new Blocks**.
- `true` ⇒ the Kiosk uses dynamic audio for that Block.
- `false` ⇒ the Kiosk uses its locally chosen audio.
- During gaps, or when schedules are off, the local audio selection applies
(existing `config.audio.dynamic_audio` / fixed track).
- Kiosk parsing defaults an absent `dynamicAudio` to `true`.
---
## Backward compatibility
All existing endpoints and payloads keep working for Kiosks already in the
field. New fields are **additive**; no v2 field is removed or renamed, and v1
is untouched.
- **Old Kiosk vs new Portal.** An old Kiosk calls
`POST /api/v2/license` (no `systemName`) and
`GET /api/v2/schedule?email&licenseKey`, reading `blocks` and ignoring
unknown fields. The Portal keeps both, keeps the `blocks` shape byte-
compatible, and adds `version`/`type`/`daily`/`weekly` as extra fields. The
old Kiosk is unaffected and ignores `systemNameStatus`.
- **Daily schedules.** For `type: "daily"`, `blocks` is the flattened daily
list; an old Kiosk behaves exactly as today.
- **Weekly schedules.** `blocks` is an **empty array**. An old Kiosk therefore
sees no schedule and falls back to local Proclamation/audio selection. It
cannot interpret weekly schedules; this is accepted and recorded under
[Open items](#open-items).
- **No schedule.** `type: null`, `blocks: []`; both old and new Kiosks fall
back to local selection.
- **Omission of `systemName`** by an old Kiosk never clears an existing name;
`systemNameStatus` is simply ignored.
- The new endpoints are additive; their absence on old Kiosks is irrelevant.
---
## Touchpoints per phase
File/module candidates each later phase will change. Phase numbering is assumed
from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy)
and is recorded under [Open items](#open-items).
### Kiosk (`radiantwave`)
- **Phase 1 (Kiosk scheduler UI, storage, sync, playback)**
- `src/network/api_client.h` / `src/network/api_client.cpp` — parse
`version`/`type`/`daily`/`weekly`, ETag/`If-None-Match`, `PUT`
system-name, `systemNameStatus`.
- `src/network/proclamation_sync.h` / `.cpp` — boot/network-up/5-minute
schedule sync, conditional GET, atomic cache.
- `src/network/license_service.h` / `.cpp` — send `systemName`, handle
`systemNameStatus`.
- `src/db/db_keys.h` — System Name / ETag keys; `src/db/database_manager.h`
/ `.cpp` — schedule `type`/day-of-week storage, cache.
- `src/scroller_page.h` / `.cpp`, `src/scroller_streams.h` / `.cpp` —
day-of-week Block selection, spillover.
- `src/timezone_helper.h` — local day-of-week.
- `src/config/app_config.h`, `src/config/config_manager.cpp`,
`src/config/yaml_parser.cpp` — settings surface.
- New page(s) for setting the System Name; tests in `tests/` registered in
`CMakeLists.txt`.
### Portal (`portal`)
- **Phase 2 (System Name + license validation)**
- `prisma/schema.prisma` + migration — `LicenseKey.systemName` and
case-insensitive uniqueness.
- `src/routes/api/v2/license/+server.ts` — `systemName` /
`systemNameStatus`.
- `src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts` — new.
- `src/lib/server/v2-serializers.ts`, `src/lib/server/v2-helpers.ts`.
- Dashboard read-only display.
- **Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)**
- `prisma/schema.prisma` + migration — Schedule per License Key, `type`,
day-of-week.
- `src/lib/server/schedule.ts`, `src/lib/schedule-utils.ts` — weekly,
per-license orchestration, cross-day validation.
- `src/lib/server/v2-serializers.ts` — `version`/`type`/`daily`/`weekly`.
- `src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts` — new
(ETag/304).
- `src/routes/api/v2/schedule/+server.ts` — additive evolution.
- `src/routes/(protected)/(user)/schedule/*`,
`src/lib/components/schedule/*` — weekly UI, per-license selection.
- Specs: `schedule.spec.ts`, `page.server.spec.ts`, `schedule-utils.spec.ts`.
- **Phase 4 (Schedule copy)**
- Copy service in `src/lib/server/schedule.ts`; copy route/action; UI.
- **All phases** — `docs/openapi.yaml`, this contract, `docs/fixtures/`.
---
## Open items
Recorded ambiguities, conservative choices, and remaining known limitations.
Items 1–21 were resolved or restated during Phases 1–4; items 22–24 are Phase 5
findings. Everything marked "Resolved" is settled for this overhaul.
1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only;
Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here.
2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the
Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no
schedule". Implemented in Phase 3.
3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained.
Phase 3 applies it **per day list** (each of the seven weekly days gets its
own 4-block budget). Whether the cap should be lower is unresolved.
4. **Field naming.** The brief's `start`/`end` were mapped to the existing
`startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk.
5. **`version` derivation.** Resolved in Phase 3: a SHA-1 content hash of the
serialized `{type, daily, weekly}` shape. Stable for equal content, changes
on any mutation.
6. **Unauthenticated key-only access.** The new endpoints authenticate by
License Key alone (per glossary). Anyone who knows a key could read the
schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known
limitation.
7. **`dynamicAudio` default flip.** DB default changed `false` → `true` for
**new** Blocks in Phase 3; existing Blocks keep their stored values.
8. **Per-user → per-License-Key migration.** Resolved in Phase 3: each existing
per-user Schedule is mapped to that account's **oldest** License Key
(`createdAt ASC`), as a daily schedule. Schedules with no owning
user/license are retained with `licenseKeyId NULL` and reported via a
migration `NOTICE` (not dropped).
9. **Cross-day spillover validation.** Implemented in Phase 3: weekly
`sun`→`mon` and daily self-wrap spillover are rejected as overlaps; the
shared `validateSchedule` is authoritative and mirrored client-side.
10. **Account scope for name uniqueness.** "Per account" = the owning User.
Legacy License Keys with only an `email` and no `userId` are an edge case
(written without a per-account uniqueness check).
11. **Geoip timezone display.** Phase 3 shows an informational note that times
run in the **Kiosk's** timezone and uses the **browser-resolved** timezone
as a fallback; no geoip dependency (paid or external) was added. The
Portal still stores no timezone.
12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no
ETag and never returns 304, to preserve old behaviour.
13. **`systemNameStatus` on v1.** Not added; v1 is unchanged.
14. **Trim/case representation.** Names are trimmed; uniqueness is
case-insensitive; the stored casing is preserved as provided.
15. **Sync cadence.** Resolved in Phase 3: the Kiosk loads its on-disk cache on
boot, then pulls on boot, on a 5-minute timer, and on demand.
16. **Atomic cache file.** Resolved in Phase 3:
`~/.local/radiantwave/schedule_cache.json`, written atomically (temp file +
rename); a corrupt cache is ignored.
17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent
or empty.
18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the
legacy schedule endpoint.
19. **Kiosk local toggle.** `proclamation.schedule_enabled` in `config.yaml`,
default **on**, surfaced as System Settings → Schedule. Not synced. When
off, the engine plays local selections but the cache keeps updating. The
older `proclamation.schedule_active` flag is retained in config but is not
consulted by the Phase 3 engine.
20. **Copy semantics.** Copies are **independent, by value** snapshots: no link
is kept to the source, and editing the source later does not affect targets
(and vice versa). A copy replaces any existing target Schedule in full
(type and blocks) and requires explicit confirmation in the UI when a target
already has one. No "copy to all" shortcut and no scheduled/linked copies
(per the Phase 4 brief). The copied schedule is validated before commit; a
source schedule that fails validation is rejected rather than copied.
21. **Copy and Kiosk code.** Verified against the Phase 3 Kiosk engine/fixture
tests that a copied payload (new Block IDs, same type/times/audio/refs) is
parsed and evaluated identically to any other; **no Kiosk changes were
required**.
22. **Phase 3 migration down is lossy for new-model data.** The `up` migration
(`20260929130000_move_schedule_to_license_key`) is additive and safe. Its
`down` path is **reversible in structure but lossy in data**: the down SQL
drops `Schedule.licenseKeyId`/`type` and `Block.day`, then deletes
`Schedule` rows whose `userId IS NULL`. Any schedule created under the
per-License-Key model (which sets `licenseKeyId` and leaves `userId` NULL)
is therefore **deleted** by a down-migration. Consequences:
- Back up `Schedule`/`Block`/`BlockProclamations` before any down-migration.
- Rolling back past Phase 3 requires restoring that data from backup; it is
**not** a data-preserving rollback.
- Rollback guidance is in
[scheduler-overhaul-release-notes.md](scheduler-overhaul-release-notes.md).
23. **Kiosk UI/E2E on real hardware not run here.** The verification ran the
Portal and the Kiosk schedule logic (engine, client, cache) via the test
harness and real HTTP against a locally running Portal; the SDL Kiosk UI was
not driven interactively because the build environment has no display. See
the manual checklist in the release notes and the Phase 5 report.
24. **Unauthenticated Kiosk endpoints remain a known limitation** (restated from
item 6): the key-only schedule and system-name endpoints authenticate by
License Key alone, mitigated only by the 10/min/IP rate limit. Authentication
is out of scope for this overhaul.
---
## Changelog
- 2026-09-29 — Initial contract created (Phase 0). Documents current state,
target time semantics, schedule data model, validation rules, endpoints
(`/api/v2/licenses/...`, additive license-validation fields), sync behavior,
System Name rules, dynamic audio, backward compatibility, touchpoints,
open items, and fixtures.
- 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and
`Block.day`; the shared `validateSchedule` is authoritative; the key-only
`GET /api/v2/licenses/{licenseKey}/schedule` (ETag/304) ships and the legacy
endpoint is extended additively; the Portal editor is per-system with a
browser-fallback timezone note; the Kiosk adds the conditional client,
atomic cache, schedule engine, and a System Settings On/Off toggle. Open
items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes.
- 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal
action (`?/copy`) backed by a single-transaction service that copies by
value with fresh Block IDs (so target ETags change), preserving `dynamicAudio`
and Proclamation order, scoped to the caller's account, and validated before
commit. Endpoint (e) is finalized; open items 20 and 21 record the copy
semantics and the Kiosk no-change verification.
- 2026-09-29 — Phase 5 (final). End-to-end verification against a locally
running Portal on a fresh database: schedule create/type/copy services and
the Kiosk-facing HTTP endpoints (validation with/without `systemName`,
system-name PUT, key-only schedule GET with ETag/304/404, legacy endpoint
parity, cross-account rejection) all pass; old-client compatibility confirmed;
migrations verified up/down (the down path is lossy, see open item 22). Adds
open items 22–24 (lossy down-migration, unrun UI-on-hardware checks, and the
restated unauthenticated-endpoint limitation) and the release notes.
+55
View File
@@ -0,0 +1,55 @@
# Scheduler Overhaul — Phase 5 Verification Results
Prepared during Phase 5 against a **locally running Portal on a fresh database**.
This records what was actually executed and observed. It is a companion to
`scheduler-overhaul-release-notes.md` (rollout steps) and
`schedule-api-contract.md` (the contract). Where a scenario could not be run
without real Kiosk hardware or an interactive display, the row is marked and the
equivalent automated coverage is cited.
Environment:
- Portal dev server (`npm run dev`) on `http://localhost:5173`, pointed at a
fresh scratch database (`rwportal_p5`) created and migrated for this phase.
- Kiosk schedule logic exercised through the C++ test harness
(`test_schedule_engine`) and the Kiosk API client's JSON parsing.
- No display/Xvfb available, so the SDL Kiosk UI was **not** driven
interactively; see the manual checklist in the release notes.
Legend: **PASS = executed**, **AUTO = verified by automated test** (not
interactively), **MANUAL = requires a human on hardware/display**.
| # | Scenario | Result | Evidence / notes |
| --- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| a | Kiosk shows AM/PM everywhere; System Settings contains Email, License Key, System Name, Schedule toggle, WiFi, Timezone, Date Format | AUTO | `test_date_format` (12h conversion), `test_settings_menu` (exactly the 7 entries incl. System Name + Schedule). UI rendering itself is MANUAL. |
| b | Validate a License Key, name the Kiosk, see the name read-only on the Portal | PASS + MANUAL | HTTP: validation without name → `200 unchanged`; with name → `200 ok`. Portal render is MANUAL (release-notes checklist). |
| c | Two systems in one account: duplicate name in any casing rejected and reverted on the Kiosk | PASS | HTTP: case-insensitive duplicate → validation `200 conflict`; `PUT` duplicate → `409 name_taken`; blank → `400 invalid_name`; unknown → `404 unknown_license`. Kiosk revert covered by `test_license_service`/`test_system_name`. |
| d | Rename a system on the Kiosk; its Schedule stays attached (License Key reference) | AUTO | Schedules are keyed by `licenseKeyId`; naming only touches `systemName`. Portal `schedule-copy`/service tests operate purely on License Key IDs. |
| e | Create a daily Schedule via the Portal picker; Kiosk fetches and applies it | PASS | Opt-in integration spec: daily schedule with 2 blocks created via the service; key-only GET returns it; captured live payload parsed and evaluated by the Kiosk (`SchedulePortalParityTest`). |
| f | Weekly: Block boundaries, adjacent (end 7:00 / start 7:00), midnight-crossing, Sunday→Monday spillover | PASS + AUTO | Integration spec: weekly sun crossing + adjacent mon blocks, Sunday spillover collision rejected. Engine tests: adjacency boundary, midnight self-wrap, Sunday→Monday spillover. |
| g | Gap plays local Proclamations; `dynamicAudio` true → dynamic, false → local | AUTO | Engine tests: gaps return no block; per-block `dynamicAudio` flag; `ScrollerPage::effectiveDynamicAudio` honored via playback wiring (build passes). |
| h | Schedule toggle Off stops schedule effects; On resumes | AUTO | `ScheduleManagerTest.DisabledReturnsNoBlock`; toggle stored in `config.yaml` (`schedule_enabled`, default on). |
| i | Schedules assigned to system A never affect system B | PASS | Integration spec: A daily, B weekly simultaneously; independent payloads. |
| j | Copy a Schedule to another named system; that Kiosk picks it up | PASS | Integration spec: copy A→second system yields daily with 2 blocks; independence confirmed; target ETag differs. Portal `schedule-copy.spec` covers overwrite/multi-target/rollback/new IDs. |
| k | Offline uses cached Schedule; corrupt cache ignored; timezone change re-evaluates | AUTO | `ScheduleCacheTest` (round-trip, corrupt ignored), `ScheduleManagerTest` (offline cache, 304 handled). Timezone is passed as wall-clock minutes (wall-clock means DST-safe). |
| l | Portal shows geoip/fallback timezone with kiosk-timezone note; manual minute entry alongside 5-minute options | AUTO | UI note is browser-fallback (no external dep); `TimeInput` tests cover 5-minute options and typed minutes. Visual render MANUAL. |
## Additional checks
| Check | Result | Notes |
| --------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Old-Kiosk compatibility (validate without `systemName`; legacy schedule endpoint) | PASS | `200 unchanged`; name never cleared; legacy `GET /api/v2/schedule` still returns `email` + `blocks`; modern vs legacy `version` equal. |
| Cross-account isolation | PASS | Legacy schedule with another account's key → `400`; copy rejects non-owned source/targets. |
| Conditional GET | PASS | Matching `If-None-Match` → `304`; stale ETag → `200`; unknown key → `404`. |
| Migration up | PASS | Applied cleanly to a fresh DB (29 migrations); fresh seed succeeded. |
| Migration down/up on realistic data | PASS (with caveat) | Phase 2 and Phase 3 down/up round-tripped structurally on a clone; **the Phase 3 down path is lossy** (deletes per-License-Key schedules). Documented in the contract (Open item 22) and release notes. |
| Portal `svelte-check` / ESLint / tests | PASS | 0 errors/warnings; 394 passed, 4 skipped (opt-in integration). |
| Kiosk build + tests | PASS | Full build; 21 test binaries pass. |
| Formatters | PASS | Portal Prettier clean except the pre-existing `.gitea/workflows/deploy.yaml` (unchanged from before this branch). Kiosk follows `.clang-format` (no binary available to run). |
## Not verified here (requires hardware/display)
The interactive SDL Kiosk UI (rendering, key handling, onboarding walkthrough)
was not driven; all Kiosk behavior above is covered by the engine/client/cache
tests and by parsing live Portal payloads. See the manual checklist in
`scheduler-overhaul-release-notes.md`.
+164
View File
@@ -0,0 +1,164 @@
# Scheduler Overhaul — Release Notes
Status: prepared for rollout. These are **instructions only**; nothing here has
been executed. The contract lives at `docs/schedule-api-contract.md` (kept
byte-identical in both repos).
This document is kept **byte-identical** in both repositories:
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/scheduler-overhaul-release-notes.md`
- `portal` (SvelteKit/Node) — `docs/scheduler-overhaul-release-notes.md`
---
## What changed
### Portal
- **Schedules are assigned to a License Key**, not a user. `Schedule` gained
`licenseKeyId` (unique, nullable) and `type` (`daily` | `weekly` | null);
`Block` gained `day` (null for daily, `mon`..`sun` for weekly) and its
`dynamicAudio` default became `true`.
- **New Kiosk endpoint** `GET /api/v2/licenses/{licenseKey}/schedule` returns the
target payload (`version`, `type`, `daily`, `weekly`, `blocks`) with an `ETag`
derived from `version`, supports conditional GET (`304`), and is `404` for an
unknown key.
- **License validation** (`POST /api/v2/license`) accepts an optional
`systemName` and returns an additive `systemNameStatus`
(`ok` | `unchanged` | `conflict` | `invalid`). Validation always succeeds; a
non-`ok` name status is advisory.
- **New Kiosk endpoint** `PUT /api/v2/licenses/{licenseKey}/system-name`
(`200` / `400 invalid_name` / `404 unknown_license` / `409 name_taken`,
idempotent).
- **System Name** is a human-readable label for a License Key. It is set only
by the Kiosk and shown read-only in the Portal (dashboard and admin lists),
falling back to displaying the License Key. There is no Portal rename path.
Names are unique per account, compared case-insensitively, trimmed, 1–40
characters.
- **Schedule editor** (protected user route `(protected)/(user)/schedule`):
a per-system picker labelled by System Name, a Daily/Weekly type selector with
confirmation when switching a populated schedule (daily→weekly prefills all
seven days; weekly→daily and clearing start empty), per-day weekly editing,
AM/PM `TimeInput` (5-minute suggestions plus manual minute entry), midnight
crossing labelled "ends next day", an on-by-default dynamic-audio toggle, gap
helper text, and an informational note that times run in the Kiosk's own
timezone (browser timezone fallback; no external GeoIP service).
- **Copy to…** copies one system's Schedule to one or more other systems in the
same account, by value, in a single database transaction (all or none). Target
Blocks get fresh IDs so target `version`/ETag changes; `dynamicAudio` and
Proclamation order are preserved. Overwriting a target that already has a
schedule requires explicit confirmation. Copies are independent.
- **OpenAPI** (`docs/openapi.yaml`) documents the new endpoints and payloads.
### Kiosk (`radiantwave`)
- **12-hour AM/PM** time display everywhere the Kiosk shows a time; storage and
parsing remain 24-hour `HH:MM`. The Date Format setting is unchanged.
- **System Settings** menu contains Email Address, License Key, **System Name**,
**Schedule** (on/off), WiFi Setup, Time Zone Setup, and Date Format. The list
is data-driven.
- **System Name** entry (trimmed, 1–40), sent on license validation and via
`PUT /api/v2/licenses/{key}/system-name`. Conflicts/invalid names are reverted
locally with a user-facing message; network failures keep the new name locally
and retry (pending-sync flag) on boot, on validation, and on a timer.
- **Schedule client**: conditional GET (`If-None-Match`) of
`GET /api/v2/licenses/{key}/schedule`, on boot, on network-up, and every
5 minutes. The last payload is cached to
`~/.local/radiantwave/schedule_cache.json` with an **atomic write** (temp file
- rename); a corrupt cache is ignored. Offline uses the cache; no cache and no
network behaves as schedules-off.
- **Schedule engine**: given the payload, the local wall-clock time in the
Kiosk's Timezone setting, and the weekday, picks the active Block. Half-open
`[start, end)` boundaries; midnight-crossing Blocks belong to their start day
(weekly Sunday spills into Monday); invalid Blocks are skipped with a warning
and treated as gaps. Active Block → its Proclamations; `dynamicAudio` true
forces dynamic audio, false uses the locally chosen audio; gaps / no schedule
/ toggle off use local selection exactly as before.
- **Schedule toggle** in System Settings (`proclamation.schedule_enabled`,
default on, Kiosk-local, not synced). Off takes no action but the cache keeps
updating.
---
## Migration notes
- **Portal migrations are additive `up` migrations** and are safe to deploy
while old Kiosks keep running:
- `20260929120000_add_license_key_system_name` — adds
`LicenseKey.systemName` / `systemNameLower` and a unique index on
`(userId, systemNameLower)`.
- `20260929130000_move_schedule_to_license_key` — adds `Schedule.licenseKeyId`
/ `type` and `Block.day`, changes the `dynamicAudio` default to `true`,
makes the legacy `Schedule.userId` optional/non-unique, and backfills each
existing per-user Schedule to that account's **oldest** License Key as a
**daily** schedule. Schedules that cannot be mapped (no owning user/license)
are retained with `licenseKeyId NULL` and reported via a migration `NOTICE`
— they are never silently dropped.
- **Existing (per-user) schedules survive the `up` migration** and are mapped to
a License Key on the same account.
- **⚠ The `down` path is lossy for data created under the new model.** Back up
`Schedule`, `Block`, and `BlockProclamations` before any down-migration.
Rolling back past the Phase 3 migration deletes per-License-Key schedules (see
the contract's Open item 22).
- No Portal schema change affects Kiosks (the Kiosk stores nothing new on the
server beyond the System Name label and its schedule mapping).
---
## Recommended rollout order
Each step is independent; do not skip the verification gates. Do **not** tag,
push, or deploy as part of reading this document — these are instructions.
### Step 1 — Run Portal migrations and deploy the Portal
1. Back up the Portal database (at minimum the `Schedule`, `Block`,
`BlockProclamations`, and `LicenseKey` tables).
2. Apply migrations: `npm run deploy` (which runs `npx prisma migrate deploy`).
3. Confirm `npx prisma migrate status` reports the database is up to date and
review the migration logs for the unmappable-schedule `NOTICE`, if any.
4. Deploy the Portal build (the existing CI/deploy path).
**Rollback (Step 1):** redeploy the previous Portal build. Because the `up`
migrations are additive, the old Portal continues to work against the migrated
schema; no schema rollback is required to redeploy the old Portal. If a schema
rollback is unavoidable, restore from the Step-1 backup (the `down` path is
lossy — see Migration notes).
### Step 2 — Verify old Kiosks still work
1. Against the deployed Portal, confirm an **old Kiosk** (no `systemName`, using
the legacy endpoints) still:
- validates via `POST /api/v2/license` (no `systemName`) and receives `200`;
- reads `GET /api/v2/schedule?email=…&licenseKey=…` and still finds its
`blocks`;
- is unaffected by the new fields, and its stored System Name (if any) is
never cleared.
2. Confirm the Portal dashboard/admin show System Names read-only.
**Rollback (Step 2):** none needed — this step is read-only verification. If old
Kiosks misbehave, roll back the Portal (Step 1 rollback) before proceeding.
### Step 3 — Release the Kiosk build
1. Build and validate the Kiosk through the normal path: tag → CI → the
auto-update channel. (Tagging triggers production CI; do it only when ready.)
2. Confirm the released build reports the expected version and updates in place.
3. Spot-check a device after update: 12-hour times, System Settings contents,
schedule on/off, and that an assigned Schedule is fetched and applied.
**Rollback (Step 3):** use the existing auto-update rollback (re-publish or
pin the previous known-good Kiosk version). The Kiosk tolerates both old and new
Portal payloads, so a Portal rollback alone does not require a Kiosk rollback,
and vice versa.
---
## Known limitations
- Kiosk API endpoints are **unauthenticated** (key-only), mitigated by a
10 requests/min/IP rate limit. Authentication is out of scope for this
overhaul.
- The Phase 3 migration's `down` path is lossy for new-model schedules
(see Migration notes and contract Open item 22).
- The Portal does not offer a rename path for System Names by design.
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Verify your email address</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Confirm your email address to activate your Radiant Wave account.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Confirm your email address
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
Thanks for signing up for Radiant Wave. Verify your email address to activate your
account.
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="{{{verification_link}}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Verify Email Address
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<p style="margin: 0; color: #64748b; font-size: 13px; line-height: 1.6">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
margin: 6px 0 0 0;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
"
>
<a
href="{{{verification_link}}}"
target="_blank"
style="color: #4f46e5; text-decoration: underline"
>{{{verification_link}}}</a
>
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
This link expires in 1 hour. If you didn't create an account, you can safely
ignore this email.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Sent to {{{target_email_address}}}<br />
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Your Radiant Wave License Key</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Here's your Radiant Wave license key.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Your license key
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
Hi {{{firstName}}}, your Radiant Wave account is ready. Here's your license key —
you'll need it to activate the app.
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 32px 0 32px">
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 12px"
>
<tr>
<td style="padding: 18px 20px; text-align: center">
<p
style="
margin: 0 0 6px 0;
color: #64748b;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
"
>
Your License Key
</p>
<p
style="
margin: 0;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.06em;
word-break: break-all;
"
>
{{{license_key}}}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 20px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="https://portal.radiantwavetech.com/dashboard"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Go to Your Dashboard
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Keep this key safe — anyone who has it can activate the app. If you didn't request
this, contact support.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Reset your Radiant Wave password</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Choose a new password for your Radiant Wave account.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Reset your password
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
We received a request to reset your Radiant Wave password. Click the button below
to choose a new one.
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="{{{reset_link}}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Reset Password
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<p style="margin: 0; color: #64748b; font-size: 13px; line-height: 1.6">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
margin: 6px 0 0 0;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
"
>
<a
href="{{{reset_link}}}"
target="_blank"
style="color: #4f46e5; text-decoration: underline"
>{{{reset_link}}}</a
>
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
This link expires in 1 hour. If you didn't request a password reset, you can
safely ignore this email — your password won't change.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Sent to {{{target_email_address}}}<br />
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Welcome to Radiant Wave</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Set your password to finish setting up your Radiant Wave account.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Welcome to Radiant Wave
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
An account has been created for you. Set a password to finish setting up your
account and access your dashboard.
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="{{{registration_link}}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Set Your Password
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<p style="margin: 0; color: #64748b; font-size: 13px; line-height: 1.6">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
margin: 6px 0 0 0;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
"
>
<a
href="{{{registration_link}}}"
target="_blank"
style="color: #4f46e5; text-decoration: underline"
>{{{registration_link}}}</a
>
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<p style="margin: 0; color: #334155; font-size: 15px; line-height: 1.6">
After you set your password, sign in and complete your profile to get started.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
This link expires in 1 hour. If you didn't expect this email, you can safely
ignore it.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Sent to {{{target_email_address}}}<br />
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+6
View File
@@ -37,5 +37,11 @@ export default defineConfig(
svelteConfig svelteConfig
} }
} }
},
{
files: ['**/*.spec.ts', '**/*.spec.js'],
rules: {
'@typescript-eslint/no-explicit-any': 'off'
}
} }
); );
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "portal.Rhemawavetech.com", "name": "portal.radiantwavetech.com",
"version": "0.0.1", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "portal.Rhemawavetech.com", "name": "portal.radiantwavetech.com",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"name": "portal.Rhemawavetech.com", "name": "portal.radiantwavetech.com",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
@@ -7,7 +7,7 @@
"node": ">=24" "node": ">=24"
}, },
"scripts": { "scripts": {
"dev": "vite dev", "dev": "NODE_EXTRA_CA_CERTS=./digital-ocean.crt vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
+9
View File
@@ -5,6 +5,15 @@ import { defineConfig } from 'prisma/config';
export default defineConfig({ export default defineConfig({
schema: 'prisma/schema.prisma', schema: 'prisma/schema.prisma',
// Legacy subscription tables are retained in the database but are no longer
// managed by Prisma Migrate. They must never be dropped (production may not
// own them and the data is kept for recovery).
experimental: {
externalTables: true
},
tables: {
external: ['public.SubscriptionLevel', 'public.ProclamationCategorySubscriptionLevel']
},
migrations: { migrations: {
path: 'prisma/migrations', path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts' seed: 'tsx prisma/seed.ts'
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "ColorPalette" (
"solfeggioFrequencyId" INTEGER NOT NULL,
"colors" JSONB NOT NULL,
CONSTRAINT "ColorPalette_pkey" PRIMARY KEY ("solfeggioFrequencyId")
);
@@ -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.
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user" ALTER COLUMN "roleId" SET DEFAULT 4;
@@ -0,0 +1,24 @@
-- Retain the legacy subscription artifacts as externally managed data.
--
-- SubscriptionLevel and ProclamationCategorySubscriptionLevel are no longer
-- modeled by the application. They are declared as external tables in
-- prisma.config.ts so Prisma Migrate ignores them entirely and never drops
-- them (production may not own these tables, and their data is kept for
-- recovery).
--
-- This migration only touches Prisma-managed tables to decouple them from the
-- legacy FK. It is intentionally idempotent so it applies cleanly to
-- production (columns present, NOT NULL, FK present), a fresh database built
-- from migration history, and development databases that already removed the
-- legacy objects.
-- Ensure the legacy columns exist. On databases built from migration history
-- these already exist; this re-adds them on environments that dropped them.
ALTER TABLE "Proclamation" ADD COLUMN IF NOT EXISTS "subscriptionLevelId" INTEGER;
ALTER TABLE "LicenseKey" ADD COLUMN IF NOT EXISTS "level" TEXT;
-- Decouple from SubscriptionLevel: drop the FK and relax NOT NULL so the
-- application can keep inserting rows without a legacy subscription value.
ALTER TABLE "Proclamation" DROP CONSTRAINT IF EXISTS "Proclamation_subscriptionLevelId_fkey";
ALTER TABLE "Proclamation" ALTER COLUMN "subscriptionLevelId" DROP NOT NULL;
ALTER TABLE "LicenseKey" ALTER COLUMN "level" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LicenseKey" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LicenseKey" ADD COLUMN "lastValidatedAt" TIMESTAMP(3);
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
"email" TEXT,
"roleId" INTEGER,
"roleName" TEXT,
"page" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT,
"entityId" TEXT,
"before" JSONB,
"after" JSONB,
"ip" TEXT,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_roleId_idx" ON "AuditLog"("roleId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_page_idx" ON "AuditLog"("page");
@@ -0,0 +1,53 @@
-- CreateTable
CREATE TABLE "Schedule" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Schedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Block" (
"id" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"startMinutes" INTEGER NOT NULL,
"endMinutes" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Block_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlockProclamations" (
"blockId" TEXT NOT NULL,
"proclamationId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlockProclamations_pkey" PRIMARY KEY ("blockId","proclamationId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Schedule_userId_key" ON "Schedule"("userId");
-- CreateIndex
CREATE INDEX "Block_scheduleId_idx" ON "Block"("scheduleId");
-- CreateIndex
CREATE INDEX "BlockProclamations_proclamationId_idx" ON "BlockProclamations"("proclamationId");
-- AddForeignKey
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Block" ADD CONSTRAINT "Block_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockProclamations" ADD CONSTRAINT "BlockProclamations_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "Block"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockProclamations" ADD CONSTRAINT "BlockProclamations_proclamationId_fkey" FOREIGN KEY ("proclamationId") REFERENCES "Proclamation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Block" ADD COLUMN "dynamicAudio" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "LicenseKey" ADD COLUMN "systemName" TEXT,
ADD COLUMN "systemNameLower" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "LicenseKey_userId_systemNameLower_key" ON "LicenseKey"("userId", "systemNameLower");
@@ -0,0 +1,46 @@
-- Phase 3: move Schedule from per-user to per-License-Key, add weekly support.
-- AlterTable: Schedule gains licenseKeyId + type, userId becomes optional.
ALTER TABLE "Schedule" ADD COLUMN "licenseKeyId" TEXT,
ADD COLUMN "type" TEXT;
-- AlterTable: Block gains day; new Blocks default dynamicAudio true.
ALTER TABLE "Block" ADD COLUMN "day" TEXT;
ALTER TABLE "Block" ALTER COLUMN "dynamicAudio" SET DEFAULT true;
-- DropIndex: userId is no longer the schedule identity.
DROP INDEX "Schedule_userId_key";
-- AlterTable: legacy owner becomes optional and non-unique.
ALTER TABLE "Schedule" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Schedule_licenseKeyId_key" ON "Schedule"("licenseKeyId");
CREATE INDEX "Schedule_userId_idx" ON "Schedule"("userId");
-- AddForeignKey
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_licenseKeyId_fkey" FOREIGN KEY ("licenseKeyId") REFERENCES "LicenseKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: map each existing per-user Schedule to the account's oldest
-- License Key. Existing content was daily-only.
UPDATE "Schedule" s
SET "licenseKeyId" = lk."id",
"type" = COALESCE(s."type", 'daily')
FROM (
SELECT DISTINCT ON ("userId") "id", "userId"
FROM "LicenseKey"
WHERE "userId" IS NOT NULL
ORDER BY "userId", "createdAt" ASC, "id" ASC
) lk
WHERE s."userId" = lk."userId"
AND s."licenseKeyId" IS NULL;
-- Report (do not silently drop) schedules that cannot be mapped to a key.
DO $$
DECLARE unmapped integer;
BEGIN
SELECT count(*) INTO unmapped FROM "Schedule" WHERE "licenseKeyId" IS NULL;
IF unmapped > 0 THEN
RAISE NOTICE 'Phase 3 migration: % Schedule row(s) could not be mapped to a License Key (no owning user/license); retained with licenseKeyId NULL.', unmapped;
END IF;
END $$;
+154 -26
View File
@@ -21,13 +21,14 @@ model User {
image String? image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
roleId Int? roleId Int? @default(4) // New users default to "User"
role UserRole? @relation(fields: [roleId], references: [id]) role UserRole? @relation(fields: [roleId], references: [id])
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
profile Profile? profiles Profile[]
licenseKeys LicenseKey[] licenseKeys LicenseKey[]
userProclamations UserProclamations[] userProclamations UserProclamations[]
schedules Schedule[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -37,13 +38,17 @@ model User {
// a bunch of time trying to figure out how to customize the default Better-Auth model. // a bunch of time trying to figure out how to customize the default Better-Auth model.
model Profile { model Profile {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
email String?
firstName String firstName String
lastName String lastName String
phoneNumber String phoneNumber String
ghlContactId String? @map("ghl_contact_id") ghlContactId String? @map("ghl_contact_id")
addresses Address[] addresses Address[]
@@index([userId])
@@index([email])
} }
model Address { model Address {
@@ -123,9 +128,20 @@ model Proclamation {
solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id]) solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])
categoryId Int categoryId Int
category ProclamationCategory @relation(fields: [categoryId], references: [id]) category ProclamationCategory @relation(fields: [categoryId], references: [id])
groupId Int
group ProclamationGroup @relation(fields: [groupId], references: [id])
userProclamations UserProclamations[] userProclamations UserProclamations[]
subscriptionLevelId Int // Keeping old FK for migration stability blockProclamations BlockProclamations[]
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id]) // Legacy subscription-level FK. Retained in the database but unmanaged; the
// FK constraint is dropped and the value is left NULL. Hidden from the client.
subscriptionLevelId Int? @ignore
}
model ProclamationGroup {
id Int @id @default(autoincrement())
name String @unique
sortOrder Int @default(0)
proclamations Proclamation[]
} }
model UserProclamations { model UserProclamations {
@@ -148,34 +164,61 @@ model SolfeggioFrequency {
model ProclamationCategory { model ProclamationCategory {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String @unique
sortOrder Int @default(0) sortOrder Int @default(0)
parentId Int?
parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children ProclamationCategory[] @relation("CategoryHierarchy")
proclamations Proclamation[] proclamations Proclamation[]
subscriptionLevels ProclamationCategorySubscriptionLevel[]
@@unique([parentId, name])
} }
// Join table for ProclamationCategory -> SubscriptionLevel defaults // Archive of the former child sub-categories removed when the
model ProclamationCategorySubscriptionLevel { // 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 categoryId Int
category ProclamationCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
subscriptionLevelId Int subscriptionLevelId Int
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id], onDelete: Cascade)
@@id([categoryId, subscriptionLevelId]) @@id([categoryId, subscriptionLevelId])
@@map("ProclamationCategorySubscriptionLevelLegacy")
@@ignore
} }
model LicenseKey { model LicenseKey {
id String @id @default(uuid()) id String @id @default(uuid())
key String @unique key String @unique
active Boolean active Boolean
level String createdAt DateTime @default(now())
userId String lastValidatedAt DateTime?
user User @relation(fields: [userId], references: [id]) userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
schedule Schedule?
// Human-readable label set only by the Kiosk (e.g. "Living Room"). Trimmed,
// 1-40 chars. Never a reference or foreign key; the License Key is the only
// identifier. `systemNameLower` is a case-folded copy used to enforce
// per-account, case-insensitive uniqueness when non-null.
systemName String?
systemNameLower String?
// Legacy subscription level. Retained in the database but unmanaged; hidden
// from the client and left NULL.
level String? @ignore
@@unique([userId, systemNameLower])
@@index([userId])
@@index([email])
} }
model ApiKey { model ApiKey {
@@ -187,10 +230,95 @@ model ApiKey {
revokedAt DateTime? revokedAt DateTime?
} }
model SubscriptionLevel { // Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).
id Int @id @default(autoincrement()) // `solfeggioFrequencyId` is the canonical id the kiosk uses (see
name String // v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.
description String? model ColorPalette {
proclamations Proclamation[] solfeggioFrequencyId Int @id
categoryLevels ProclamationCategorySubscriptionLevel[] colors Json // ["#RRGGBB", ... x7]
@@map("ColorPalette")
}
// Append-only audit trail of database mutations. Written centrally by the
// Prisma extension in src/lib/db.ts; read by the admin Audit Log page.
model AuditLog {
id String @id @default(uuid())
createdAt DateTime @default(now())
userId String?
email String?
roleId Int?
roleName String?
page String
action String
entity String?
entityId String?
before Json?
after Json?
ip String?
@@index([createdAt])
@@index([roleId])
@@index([action])
@@index([page])
@@map("AuditLog")
}
// A listening schedule assigned to one License Key (at most one per key).
// `type` is "daily" | "weekly" | null (null = no schedule). Times are
// wall-clock minutes from midnight (0-1440), interpreted by the kiosk in its
// own configured timezone. The portal never stores a timezone.
//
// `userId` is a legacy owner retained while per-user schedules are migrated to
// per-license-key; new schedules set `licenseKeyId` and leave `userId` null.
model Schedule {
id String @id @default(uuid())
licenseKeyId String? @unique
licenseKey LicenseKey? @relation(fields: [licenseKeyId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
type String?
blocks Block[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// A single time range within a Schedule, plus the ordered set of selected
// Proclamations to play during it. `day` is null for daily schedules and
// "mon".."sun" for weekly schedules. Overnight (midnight-crossing) ranges are
// valid: an end clock time before the start belongs to the start day and spills
// into the next day.
model Block {
id String @id @default(uuid())
scheduleId String
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
day String?
startMinutes Int
endMinutes Int
// When true, the kiosk enables dynamic audio for the duration of this block,
// overriding any audio selections made on the device. Portal default: true.
dynamicAudio Boolean @default(true)
proclamations BlockProclamations[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scheduleId])
}
// Join table: the Proclamations selected for a Block. Carries the Proclamation
// content for playback via the API; the portal UI only ever reads
// title/category/group.
model BlockProclamations {
blockId String
block Block @relation(fields: [blockId], references: [id], onDelete: Cascade)
proclamationId String
proclamation Proclamation @relation(fields: [proclamationId], references: [id])
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([blockId, proclamationId])
@@index([proclamationId])
} }
+81 -41
View File
@@ -21,46 +21,77 @@ const solfeggioFrequencies = [
{ id: 4, name: 'Heart', value: 639 }, { id: 4, name: 'Heart', value: 639 },
{ id: 5, name: 'Solar Plexus', value: 528 }, { id: 5, name: 'Solar Plexus', value: 528 },
{ id: 6, name: 'Sacral', value: 417 }, { id: 6, name: 'Sacral', value: 417 },
{ id: 7, name: 'Root', value: 396 } { id: 7, name: 'Root', value: 396 },
// Canonical frequencies missing from the original 7-row set. Kept as
// separate rows (with distinct unique names) so the canonical id mapping
// in v2-serializers.ts can resolve 1-9 without renumbering v1 rows.
{ id: 8, name: 'Grounding / Relief', value: 174 },
{ id: 9, name: 'Relief', value: 285 },
{ id: 10, name: 'Clarity / Wellbeing', value: 432 },
{ id: 11, name: 'Crown 963', value: 963 }
]; ];
const subscriptionLevels = [ // Canonical id -> 7-color palette, matching the kiosk's
{ id: 1, name: 'Basic', description: 'General only' }, // DatabaseManager::seedColorPalettes() (radiantwave-rebuild).
{ id: 2, name: 'Advanced', description: 'General + Specific' }, const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [
{ id: 3, name: 'Elite', description: 'General + Specific + Precise' } {
solfeggioFrequencyId: 1,
colors: ['#8B6914', '#B8860B', '#DAA520', '#E8B64C', '#FFD700', '#FFE9A8', '#FFF4CC']
},
{
solfeggioFrequencyId: 2,
colors: ['#8B0000', '#B22222', '#DC143C', '#FF6347', '#FF8C69', '#FFA500', '#FFDAB9']
},
{
solfeggioFrequencyId: 3,
colors: ['#FF4500', '#FF7F50', '#FF8C00', '#FFA500', '#FFC87F', '#FFDFBA', '#FFEBCD']
},
{
solfeggioFrequencyId: 4,
colors: ['#8B2252', '#CD3278', '#DB7093', '#FF69B4', '#FFB6C1', '#FFC0CB', '#FFE4E1']
},
{
solfeggioFrequencyId: 5,
colors: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0']
},
{
solfeggioFrequencyId: 6,
colors: ['#00FF7F', '#00CED1', '#40E0D0', '#5F9EA0', '#7FFFD4', '#AFEEEE', '#E0FFFF']
},
{
solfeggioFrequencyId: 7,
colors: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF']
},
{
solfeggioFrequencyId: 8,
colors: ['#4B0082', '#9370DB', '#8A2BE2', '#7B68EE', '#DA70D6', '#D8BFD8', '#E6E6FA']
},
{
solfeggioFrequencyId: 9,
colors: ['#9400D3', '#BA55D3', '#C71585', '#DDA0DD', '#EE82EE', '#FFF0F5', '#FAF0FA']
}
]; ];
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 = [ const proclamationCategories = [
{ id: 4, name: 'Spiritual', parentId: null }, { id: 4, name: 'Spiritual' },
{ id: 5, name: 'General', parentId: 4 }, { id: 2, name: 'Physical' },
{ id: 9, name: 'Specific', parentId: 4 }, { id: 16, name: 'Mental' },
{ id: 40, name: 'Precise', parentId: 4 }, { id: 17, name: 'Custom (Client Specific)' },
{ id: 2, name: 'Physical', parentId: null }, { id: 19, name: 'Financial' },
{ id: 3, name: 'General', parentId: 2 }, { id: 20, name: 'Emotional' },
{ id: 10, name: 'Specific', parentId: 2 }, { id: 21, name: 'Social' },
{ id: 32, name: 'Precise', parentId: 2 }, { id: 41, name: 'Biblical' },
{ id: 16, name: 'Mental', parentId: null }, { id: 42, name: 'Personal Growth & Development' },
{ id: 30, name: 'General', parentId: 16 }, { id: 46, name: 'Sports' }
{ 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 }
]; ];
async function main() { async function main() {
@@ -82,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...'); console.log('Seeding ProclamationCategory...');
for (const cat of proclamationCategories) { for (const cat of proclamationCategories) {
await prisma.proclamationCategory.upsert({ await prisma.proclamationCategory.upsert({
@@ -91,12 +131,12 @@ async function main() {
}); });
} }
console.log('Seeding SubscriptionLevel...'); console.log('Seeding ColorPalette...');
for (const level of subscriptionLevels) { for (const palette of colorPalettes) {
await prisma.subscriptionLevel.upsert({ await prisma.colorPalette.upsert({
where: { id: level.id }, where: { solfeggioFrequencyId: palette.solfeggioFrequencyId },
create: level, create: palette,
update: level update: palette
}); });
} }
+163
View File
@@ -0,0 +1,163 @@
import 'dotenv/config';
// Exercises the Kiosk-facing HTTP API exactly as the Kiosk client does.
// Scenarios: b (validate + name), c (duplicate name conflict), k (ETag/304),
// old-client backward compatibility.
const BASE = process.env.P5_BASE ?? 'http://localhost:5173';
const KA1 = 'ALCE-AAAA-AAAA-0001';
const KA2 = 'ALCE-AAAA-AAAA-0002';
const KB1 = 'BOBB-BBBB-BBBB-0001';
const EMAIL = 'p5-alice@example.com';
const results: { id: string; pass: boolean; note: string }[] = [];
function rec(id: string, pass: boolean, note: string) {
results.push({ id, pass, note });
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${note}`);
}
async function post(path: string, body: unknown) {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.json().catch(() => null), headers: res.headers };
}
async function put(path: string, body: unknown) {
const res = await fetch(BASE + path, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function get(path: string, headers: Record<string, string> = {}) {
const res = await fetch(BASE + path, { headers });
return { status: res.status, body: await res.json().catch(() => null), headers: res.headers };
}
async function main() {
// (b) validate without a name first (old-client behavior), then set a name.
const validateNoName = await post('/api/v2/license', {
license_key: KA1,
email_address: EMAIL
});
rec(
'b1',
validateNoName.status === 200 && validateNoName.body?.systemNameStatus === 'unchanged',
`validation without systemName -> ${validateNoName.status}, status=${validateNoName.body?.systemNameStatus}`
);
const setName = await post('/api/v2/license', {
license_key: KA1,
email_address: EMAIL,
systemName: 'Living Room'
});
rec(
'b2',
setName.status === 200 && setName.body?.systemNameStatus === 'ok',
`name a Kiosk -> ${setName.status}, status=${setName.body?.systemNameStatus}`
);
// (c) duplicate name, different casing, same account -> conflict + not cleared.
const dup = await post('/api/v2/license', {
license_key: KA2,
email_address: EMAIL,
systemName: 'living room'
});
rec(
'c1',
dup.status === 200 && dup.body?.systemNameStatus === 'conflict',
`case-insensitive duplicate -> status=${dup.body?.systemNameStatus} (validation still ${dup.status})`
);
// Confirm the original name survived and KA2 has no name.
const after = await get('/api/v2/licenses/' + KA1 + '/schedule');
rec('c2', after.status === 200, `KA1 schedule GET after conflict -> ${after.status}`);
// (c) PUT system-name endpoint: 409 for a taken name.
const putDup = await put('/api/v2/licenses/' + KA2 + '/system-name', {
systemName: 'LIVING ROOM'
});
rec(
'c3',
putDup.status === 409 && putDup.body?.message === 'name_taken',
`PUT duplicate -> ${putDup.status} ${putDup.body?.message}`
);
const putOk = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: 'Kitchen' });
rec('c4', putOk.status === 200, `PUT unique name -> ${putOk.status}`);
const putInvalid = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: ' ' });
rec(
'c5',
putInvalid.status === 400 && putInvalid.body?.message === 'invalid_name',
`PUT blank -> ${putInvalid.status} ${putInvalid.body?.message}`
);
const putUnknown = await put('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/system-name', {
systemName: 'X'
});
rec(
'c6',
putUnknown.status === 404 && putUnknown.body?.message === 'unknown_license',
`PUT unknown license -> ${putUnknown.status} ${putUnknown.body?.message}`
);
// (k) schedule GET parity between the legacy endpoint and the new key-only endpoint.
const modern = await get('/api/v2/licenses/' + KA1 + '/schedule');
const legacy = await get(
'/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KA1
);
rec(
'k1',
modern.status === 200 && legacy.status === 200 && modern.body?.version === legacy.body?.version,
`modern vs legacy version equal=${modern.body?.version === legacy.body?.version}`
);
// (k) conditional GET: matching If-None-Match -> 304.
const etag = modern.headers.get('etag') ?? '';
const notModified = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': etag });
rec('k2', notModified.status === 304, `conditional GET with ETag -> ${notModified.status}`);
const stale = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': '"stale"' });
rec('k3', stale.status === 200, `conditional GET with stale ETag -> ${stale.status}`);
// Unknown license -> 404.
const missing = await get('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/schedule');
rec('k4', missing.status === 404, `unknown license schedule GET -> ${missing.status}`);
// Old-client validation (no systemName field at all) never clears the name.
const validateAgain = await post('/api/v2/license', { license_key: KA1, email_address: EMAIL });
const checkName = await put('/api/v2/licenses/' + KA1 + '/system-name', {
systemName: 'Living Room'
});
rec(
'old1',
validateAgain.status === 200 &&
validateAgain.body?.systemNameStatus === 'unchanged' &&
checkName.status === 200,
`old-client validate stays unchanged; name still present`
);
// Old-client list endpoint shape: blocks array still present.
rec(
'old2',
legacy.status === 200 && Array.isArray(legacy.body?.blocks),
`legacy endpoint still returns email+blocks (${legacy.body?.blocks?.length} blocks)`
);
// Cross-account: Bob's key with Alice's email must fail.
const cross = await get(
'/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KB1
);
rec('auth1', cross.status === 400, `cross-account legacy schedule -> ${cross.status}`);
console.log('\nP5_HTTP_RESULTS ' + JSON.stringify(results));
}
main().catch((e) => {
console.error('RUNNER ERROR', e);
process.exit(1);
});
+45
View File
@@ -0,0 +1,45 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
// A handful of real Proclamations so schedule Blocks have valid references.
async function main() {
const category = await prisma.proclamationCategory.findFirst({ orderBy: { id: 'asc' } });
const group = await prisma.proclamationGroup.findFirst({ orderBy: { id: 'asc' } });
const freq = await prisma.solfeggioFrequency.findFirst({ orderBy: { id: 'asc' } });
if (!category || !group || !freq) throw new Error('seed missing catalog');
const names = ['P5 Dawn', 'P5 Dusk', 'P5 Noon', 'P5 Night'];
const ids: string[] = [];
for (const name of names) {
const existing = await prisma.proclamation.findUnique({ where: { name } });
if (existing) {
ids.push(existing.id);
continue;
}
const created = await prisma.proclamation.create({
data: {
name,
content: `<p>${name} content</p>`,
categoryId: category.id,
groupId: group.id,
solfeggioFrequencyId: freq.id
}
});
ids.push(created.id);
}
console.log(JSON.stringify(ids));
await prisma.$disconnect();
await pool.end();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
// Two accounts, each with two License Keys, for scenario coverage:
// - acct A: keys KA1, KA2 (same account -> name uniqueness + copy)
// - acct B: key KB1 (cross-account isolation)
// Names are left NULL; the Kiosk sets them via the API.
const accounts = [
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0001' },
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0002' },
{ email: 'p5-bob@example.com', key: 'BOBB-BBBB-BBBB-0001' }
];
async function ensureUser(email) {
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({
data: { id: crypto.randomUUID(), name: email.split('@')[0], email, emailVerified: true }
});
}
return user;
}
async function main() {
for (const { email, key } of accounts) {
const user = await ensureUser(email);
const existing = await prisma.licenseKey.findUnique({ where: { key } });
if (!existing) {
await prisma.licenseKey.create({ data: { key, active: true, userId: user.id } });
}
}
console.log('P5 test data ready:');
for (const { email, key } of accounts) console.log(` ${email} -> ${key}`);
await prisma.$disconnect();
await pool.end();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
const schedules = await prisma.schedule.findMany({ include: { blocks: true } });
const keys = await prisma.licenseKey.findMany({ select: { key: true, systemName: true } });
console.log(
JSON.stringify({
scheduleCount: schedules.length,
blockCount: schedules.reduce((n, s) => n + s.blocks.length, 0),
types: schedules.map((s) => s.type),
names: keys
.filter((k) => k.systemName)
.map((k) => k.systemName)
.sort()
})
);
await prisma.$disconnect();
await pool.end();
+5 -3
View File
@@ -1,10 +1,12 @@
import type { Session, User } from 'better-auth'; import type { auth } from '$lib/auth';
import type { ApiKeyRecord } from '$lib/server/api-key';
declare global { declare global {
namespace App { namespace App {
interface Locals { interface Locals {
session: Session | null; session: typeof auth.$Infer.Session.session | null;
user: User | null; user: typeof auth.$Infer.Session.user | null;
apiKey?: ApiKeyRecord;
} }
} }
} }
+8 -8
View File
@@ -5,21 +5,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script> <script>
(function () { (function () {
try {
const theme = localStorage.getItem('theme'); const theme = localStorage.getItem('theme');
if ( const dark =
theme === 'dark' || theme === 'dark' ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches) (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
) { document.documentElement.classList.toggle('dark', dark);
document.documentElement.classList.add('dark'); } catch (e) {
} else { // ignore
document.documentElement.classList.remove('dark');
} }
})(); })();
</script> </script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body class="m-0" data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div class="flex h-dvh w-dvw flex-col">%sveltekit.body%</div> <div>%sveltekit.body%</div>
</body> </body>
</html> </html>
+114 -8
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { RequestEvent } from '@sveltejs/kit';
const { getSession } = vi.hoisted(() => ({ const { getSession } = vi.hoisted(() => ({
getSession: vi.fn() getSession: vi.fn()
@@ -8,10 +9,19 @@ const { svelteKitHandler } = vi.hoisted(() => ({
svelteKitHandler: vi.fn() svelteKitHandler: vi.fn()
})); }));
const { validateApiKeyMock, envMock } = vi.hoisted(() => ({
validateApiKeyMock: vi.fn(),
envMock: { API_KEYS_ACTIVE: undefined as string | undefined }
}));
vi.mock('$app/environment', () => ({ vi.mock('$app/environment', () => ({
building: false building: false
})); }));
vi.mock('$env/dynamic/private', () => ({
env: envMock
}));
vi.mock('better-auth/svelte-kit', () => ({ vi.mock('better-auth/svelte-kit', () => ({
svelteKitHandler svelteKitHandler
})); }));
@@ -25,24 +35,33 @@ vi.mock('$lib/auth', () => ({
})); }));
vi.mock('$lib/server/api-key', () => ({ vi.mock('$lib/server/api-key', () => ({
validateApiKey: vi.fn() validateApiKey: validateApiKeyMock
})); }));
import { handle } from './hooks.server'; import { handle } from './hooks.server';
function createMockEvent(path = '/dashboard') { type MockRequestEvent = RequestEvent & { resolve: ReturnType<typeof vi.fn> };
function createMockEvent(path = '/dashboard'): MockRequestEvent {
return { return {
request: new Request(`http://localhost${path}`), request: new Request(`http://localhost${path}`),
url: new URL(`http://localhost${path}`), url: new URL(`http://localhost${path}`),
locals: {} as Record<string, unknown>, locals: {} as Record<string, unknown>,
resolve: vi.fn() resolve: vi.fn()
} as any; } as unknown as MockRequestEvent;
}
function runHandle(event: MockRequestEvent) {
type HandleArgs = Parameters<typeof handle>[0];
return handle({ event, resolve: event.resolve as unknown as HandleArgs['resolve'] });
} }
describe('hooks.server handle', () => { describe('hooks.server handle', () => {
beforeEach(() => { beforeEach(() => {
getSession.mockReset(); getSession.mockReset();
svelteKitHandler.mockReset(); svelteKitHandler.mockReset();
validateApiKeyMock.mockReset();
envMock.API_KEYS_ACTIVE = undefined;
}); });
it('sets session and user on locals when authenticated', async () => { it('sets session and user on locals when authenticated', async () => {
@@ -53,7 +72,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(sessionData); getSession.mockResolvedValue(sessionData);
const event = createMockEvent(); const event = createMockEvent();
await handle({ event, resolve: event.resolve }); await runHandle(event);
expect(event.locals.session).toEqual(sessionData.session); expect(event.locals.session).toEqual(sessionData.session);
expect(event.locals.user).toEqual(sessionData.user); expect(event.locals.user).toEqual(sessionData.user);
@@ -70,7 +89,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(null); getSession.mockResolvedValue(null);
const event = createMockEvent(); const event = createMockEvent();
await handle({ event, resolve: event.resolve }); await runHandle(event);
expect(event.locals.session).toBeUndefined(); expect(event.locals.session).toBeUndefined();
expect(event.locals.user).toBeUndefined(); expect(event.locals.user).toBeUndefined();
@@ -80,7 +99,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(undefined); getSession.mockResolvedValue(undefined);
const event = createMockEvent(); const event = createMockEvent();
await handle({ event, resolve: event.resolve }); await runHandle(event);
expect(event.locals.session).toBeUndefined(); expect(event.locals.session).toBeUndefined();
expect(event.locals.user).toBeUndefined(); expect(event.locals.user).toBeUndefined();
@@ -90,7 +109,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(null); getSession.mockResolvedValue(null);
const event = createMockEvent(); const event = createMockEvent();
await handle({ event, resolve: event.resolve }); await runHandle(event);
expect(svelteKitHandler).toHaveBeenCalledWith(expect.objectContaining({ building: false })); expect(svelteKitHandler).toHaveBeenCalledWith(expect.objectContaining({ building: false }));
}); });
@@ -99,7 +118,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(null); getSession.mockResolvedValue(null);
const event = createMockEvent(); const event = createMockEvent();
await handle({ event, resolve: event.resolve }); await runHandle(event);
expect(svelteKitHandler).toHaveBeenCalledWith( expect(svelteKitHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -109,4 +128,91 @@ describe('hooks.server handle', () => {
}) })
); );
}); });
it('does not apply API key gate to non-API routes', async () => {
getSession.mockResolvedValue(null);
const event = createMockEvent('/dashboard');
await runHandle(event);
expect(validateApiKeyMock).not.toHaveBeenCalled();
expect(svelteKitHandler).toHaveBeenCalled();
});
// TODO: re-enable when v2 API key auth is ready.
// describe('v2 auth gate', () => {
// it('returns 401 for v2 routes without an Authorization header', async () => {
// const event = createMockEvent('/api/v2/proclamations');
// const res = await runHandle(event);
//
// expect(res.status).toBe(401);
// expect(await res.json()).toEqual({ error: 'API key required' });
// expect(validateApiKeyMock).not.toHaveBeenCalled();
// expect(event.resolve).not.toHaveBeenCalled();
// });
//
// it('returns 401 for v2 routes with an invalid bearer key', async () => {
// validateApiKeyMock.mockResolvedValue(null);
//
// const event = createMockEvent('/api/v2/proclamations');
// event.request = new Request('http://localhost/api/v2/proclamations', {
// headers: { authorization: 'Bearer sk_invalid' }
// });
// const res = await runHandle(event);
//
// expect(res.status).toBe(401);
// expect(await res.json()).toEqual({ error: 'Invalid or expired API key' });
// expect(validateApiKeyMock).toHaveBeenCalledWith('sk_invalid');
// expect(event.resolve).not.toHaveBeenCalled();
// });
//
// it('resolves v2 routes with a valid bearer key and sets locals.apiKey', async () => {
// validateApiKeyMock.mockResolvedValue({ id: 'key-1', keyPrefix: 'sk_abc123' });
//
// const event = createMockEvent('/api/v2/proclamations');
// event.request = new Request('http://localhost/api/v2/proclamations', {
// headers: { authorization: 'Bearer sk_abc123def' }
// });
// await runHandle(event);
//
// expect(event.resolve).toHaveBeenCalled();
// expect(event.locals.apiKey).toEqual({ id: 'key-1', keyPrefix: 'sk_abc123' });
// expect(validateApiKeyMock).toHaveBeenCalledWith('sk_abc123def');
// });
//
// it('always validates the v2 API key even when API_KEYS_ACTIVE would bypass v1', async () => {
// envMock.API_KEYS_ACTIVE = 'false';
// validateApiKeyMock.mockResolvedValue(null);
//
// const event = createMockEvent('/api/v2/frequencies');
// event.request = new Request('http://localhost/api/v2/frequencies', {
// headers: { authorization: 'Bearer sk_abc' }
// });
// const res = await runHandle(event);
//
// expect(res.status).toBe(401);
// expect(await res.json()).toEqual({ error: 'Invalid or expired API key' });
// expect(validateApiKeyMock).toHaveBeenCalledWith('sk_abc');
// });
//
// it('does not require an API key for signed v2 onboarding routes', async () => {
// getSession.mockResolvedValue(null);
//
// const event = createMockEvent('/api/v2/automations/create/account');
// await runHandle(event);
//
// expect(validateApiKeyMock).not.toHaveBeenCalled();
// expect(svelteKitHandler).toHaveBeenCalled();
// });
//
// it('does not apply the v2 gate to non-API routes', async () => {
// getSession.mockResolvedValue(null);
//
// const event = createMockEvent('/dashboard');
// await runHandle(event);
//
// expect(validateApiKeyMock).not.toHaveBeenCalled();
// expect(svelteKitHandler).toHaveBeenCalled();
// });
// });
}); });
+53
View File
@@ -1,13 +1,60 @@
import { auth } from '$lib/auth'; import { auth } from '$lib/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit'; import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment'; import { building } from '$app/environment';
import { env } from '$env/dynamic/private';
import { validateApiKey } from '$lib/server/api-key'; import { validateApiKey } from '$lib/server/api-key';
import { runWithAuditContext, type AuditContext } from '$lib/server/audit-context';
import { clientIp } from '$lib/server/v2-helpers';
function contextFor(
event: { url: URL; request: Request },
session?: App.Locals['user']
): AuditContext {
return {
userId: session?.id ?? null,
email: session?.email ?? null,
roleId: session?.roleId ?? null,
page: event.url.pathname,
ip: clientIp(event.request)
};
}
export async function handle({ event, resolve }) { export async function handle({ event, resolve }) {
return runWithAuditContext(contextFor(event), async () => {
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
// exceptions are the signed onboarding routes (HMAC-protected).
// TODO: re-enable when v2 API key auth is ready.
// const isV2 = event.url.pathname.startsWith('/api/v2/');
// const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/');
//
// if (isV2 && !isSignedOnboarding) {
// 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 key = authHeader.slice(7);
// const record = await validateApiKey(key);
// if (!record) {
// return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
// status: 401,
// headers: { 'content-type': 'application/json' }
// });
// }
//
// event.locals.apiKey = record;
//
// return resolve(event);
// }
if ( if (
event.url.pathname.startsWith('/api/v1/') && event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/') !event.url.pathname.startsWith('/api/v1/automations/')
) { ) {
if (env.API_KEYS_ACTIVE !== 'false') {
const authHeader = event.request.headers.get('authorization'); const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), { return new Response(JSON.stringify({ error: 'API key required' }), {
@@ -24,6 +71,7 @@ export async function handle({ event, resolve }) {
headers: { 'content-type': 'application/json' } headers: { 'content-type': 'application/json' }
}); });
} }
}
return resolve(event); return resolve(event);
} }
@@ -35,7 +83,12 @@ export async function handle({ event, resolve }) {
if (session) { if (session) {
event.locals.session = session.session; event.locals.session = session.session;
event.locals.user = session.user; event.locals.user = session.user;
return runWithAuditContext(contextFor(event, session.user), () =>
svelteKitHandler({ event, resolve, auth, building })
);
} }
return svelteKitHandler({ event, resolve, auth, building }); return svelteKitHandler({ event, resolve, auth, building });
});
} }
+36 -1
View File
@@ -5,13 +5,40 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server'; import { getRequestEvent } from '$app/server';
import { sendMail } from './server/resend'; import { sendMail } from './server/resend';
import { sendVerificationEmail } from './server/email-verification'; import { sendVerificationEmail } from './server/email-verification';
import { sendRegistrationEmail } from './server/registration-email';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
/**
* An admin-created user is invited with a set-password link. That reuses the
* password-reset token flow but marks the request with `invite=1` so we send a
* welcome/registration email instead of a "you requested a reset" email.
*/
function isRegistrationInvite(url: string): boolean {
try {
const callbackURL = new URL(url).searchParams.get('callbackURL') ?? '';
return callbackURL.includes('invite=1');
} catch {
return false;
}
}
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL, baseURL: env.BETTER_AUTH_URL,
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: 'postgresql' provider: 'postgresql'
}), }),
user: {
// Expose our custom role column on the session user so role checks can
// read `locals.user.roleId` without a database round-trip.
additionalFields: {
roleId: {
type: 'number',
required: false,
defaultValue: 4,
input: false
}
}
},
emailVerification: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
sendVerificationEmail: async ({ user, token }) => { sendVerificationEmail: async ({ user, token }) => {
@@ -27,8 +54,16 @@ export const auth = betterAuth({
enabled: true, enabled: true,
requireEmailVerification: true, requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
if (isRegistrationInvite(url)) {
void sendRegistrationEmail({ email: user.email, url }).catch((e) => {
console.error('sendRegistrationEmail template email failed:', e);
});
return;
}
void sendMail({ void sendMail({
to: user.email, to: user.email,
subject: 'Reset your Radiant Wave password',
templateId: 'password-reset-email', templateId: 'password-reset-email',
data: { data: {
target_email_address: user.email, target_email_address: user.email,
@@ -51,6 +86,6 @@ export const auth = betterAuth({
window: 60, window: 60,
max: 100 max: 100
}, },
trustedOrigins: ['http://localhost:5173', 'https://portal.rhemawavetech.com'], trustedOrigins: ['http://localhost:5173', 'https://portal.radiantwavetech.com'],
plugins: [sveltekitCookies(getRequestEvent)] plugins: [sveltekitCookies(getRequestEvent)]
}); });
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description = '',
children
}: {
title: string;
description?: string;
children: Snippet;
} = $props();
</script>
<section class="mt-8">
<div>
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{#if description}
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{description}</p>
{/if}
</div>
<div class="mt-4">
{@render children()}
</div>
</section>
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
export type DataTableColumn = {
label: string;
value: (record: Record<string, unknown>) => unknown;
};
let {
title = '',
columns = [] as DataTableColumn[],
records = [] as Record<string, unknown>[],
newLabel = 'New',
searchPlaceholder = 'Search...',
copyableFields = [] as string[],
draggable = false,
onRowClick,
onNew,
onReorder
}: {
title?: string;
columns?: DataTableColumn[];
records?: Record<string, unknown>[];
newLabel?: string;
searchPlaceholder?: string;
copyableFields?: string[];
draggable?: boolean;
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
const filteredRecords = $derived(
search
? records.filter((record) =>
columns.some((column) =>
String(column.value(record) ?? '')
.toLowerCase()
.includes(search.toLowerCase())
)
)
: records
);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
const rows = $derived(dragOverride ?? filteredRecords);
function rowKey(record: Record<string, unknown>, index: number): string {
return String(record.id ?? record.key ?? index);
}
function copyValue(value: unknown) {
navigator.clipboard.writeText(String(value ?? ''));
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = filteredRecords;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
dragIndex = null;
hoverIndex = null;
}
</script>
<div class="mx-auto w-full">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<input
type="search"
bind:value={search}
placeholder={searchPlaceholder}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none sm:w-64 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if onNew}
<button
type="button"
onclick={onNew}
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"><path d="M12 5v14" /><path d="M5 12h14" /></svg
>
{newLabel}
</button>
{/if}
</div>
</div>
<div
class="w-full overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<table class="w-full min-w-max text-left text-sm text-slate-700 dark:text-slate-300">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400"
>
{#if draggable}
<th scope="col" class="w-10 px-4 py-3"></th>
{/if}
{#each columns as column (column.label)}
<th scope="col" class="px-4 py-3">{column.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as record, i (rowKey(record, i))}
<tr
class="transition {i % 2 === 0
? 'bg-white dark:bg-slate-900'
: 'bg-slate-50 dark:bg-slate-950'} {onRowClick
? 'cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-950'
: ''}"
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
>
{#if draggable}
<td class="px-4 py-3" onclick={(e) => e.stopPropagation()} aria-hidden="true">
<span
class="cursor-grab text-slate-400 select-none active:cursor-grabbing dark:text-slate-500"
title="Drag to reorder">⠿</span
>
</td>
{/if}
{#each columns as column (column.label)}
<td class="px-4 py-3 whitespace-nowrap">
{#if copyableFields.includes(column.label) && column.value(record) != null}
<span>
{String(column.value(record))}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(column.value(record));
}}
title="Copy to clipboard"
class="ml-1 inline-flex text-slate-400 transition hover:text-indigo-600 dark:text-slate-500 dark:hover:text-indigo-400"
>
<svg
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
</span>
{:else}
{String(column.value(record) ?? '')}
{/if}
</td>
{/each}
</tr>
{:else}
<tr>
<td
colspan={columns.length + (draggable ? 1 : 0)}
class="bg-white dark:bg-slate-900 px-4 py-10 text-center text-slate-500 dark:text-slate-400"
>
No results found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import {
fiveMinuteOptions,
formatTime,
hmToMinutes,
parseTimeInput,
toHm
} from '$lib/schedule-utils';
// A reusable 12-hour clock input. Displays and accepts AM/PM times, offers
// the 5-minute options as suggestions, and allows typing any minute 00-59.
// Its value model is a 24-hour "HH:MM" string.
let {
value = '00:00',
label,
onchange,
onvalidity,
required = false,
disabled = false
}: {
value?: string;
label?: string;
onchange?: (value: string) => void;
onvalidity?: (valid: boolean) => void;
required?: boolean;
disabled?: boolean;
} = $props();
const inputId = $props.id();
const options = fiveMinuteOptions();
// While the user is typing we show their raw text (`editing`); otherwise we
// show the canonical 12-hour label derived from the 24-hour value.
let editing = $state<string | null>(null);
const valueMinutes = $derived(hmToMinutes(value) ?? 0);
const display = $derived(editing ?? formatTime(valueMinutes));
const error = $derived.by(() => {
if (editing === null) return '';
const result = parseTimeInput(editing);
return result.ok ? '' : result.error;
});
function handleInput(event: Event) {
const raw = (event.currentTarget as HTMLInputElement).value;
editing = raw;
const result = parseTimeInput(raw);
if (result.ok) {
onvalidity?.(true);
onchange?.(toHm(result.minutes));
} else {
onvalidity?.(false);
}
}
function handleBlur() {
if (editing === null) return;
const result = parseTimeInput(editing);
if (result.ok) {
// Drop back to the canonical label derived from the value.
editing = null;
onvalidity?.(true);
}
}
function handleFocus(event: FocusEvent) {
editing = (event.currentTarget as HTMLInputElement).value;
}
</script>
<div class="flex flex-col gap-1">
{#if label}
<label for={inputId} class="text-sm font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
{/if}
<input
id={inputId}
type="text"
inputmode="text"
autocomplete="off"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
list={`${inputId}-options`}
{required}
{disabled}
aria-label={label}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : undefined}
value={display}
oninput={handleInput}
onfocus={handleFocus}
onblur={handleBlur}
/>
<datalist id={`${inputId}-options`}>
{#each options as minutes (minutes)}
<option value={formatTime(minutes)}></option>
{/each}
</datalist>
{#if error}
<p id={`${inputId}-error`} role="alert" class="text-sm text-red-600 dark:text-red-400">
{error}
</p>
{/if}
</div>
@@ -0,0 +1,66 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import TimeInput from './TimeInput.svelte';
function mount(props: Record<string, unknown> = {}) {
return render(TimeInput, { value: '07:05', label: 'Time', ...props });
}
async function type(text: string) {
const input = (await page.getByRole('combobox', { name: 'Time' }).element()) as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('TimeInput', () => {
it('displays the 24-hour value as a 12-hour label', async () => {
mount({ value: '21:05' });
await expect.element(page.getByRole('combobox', { name: 'Time' })).toHaveValue('9:05 PM');
});
it('calls onchange with a 24-hour value for tolerant input', async () => {
const onchange = vi.fn();
mount({ onchange });
await type('7:05 pm');
expect(onchange).toHaveBeenCalledWith('19:05');
});
it('accepts typed minutes that are not 5-minute steps', async () => {
const onchange = vi.fn();
mount({ value: '00:00', onchange });
await type('7:07');
expect(onchange).toHaveBeenCalledWith('07:07');
});
it('accepts an hour with no minutes', async () => {
const onchange = vi.fn();
mount({ onchange });
await type('7pm');
expect(onchange).toHaveBeenCalledWith('19:00');
});
it('shows clear feedback and reports invalid input', async () => {
const onvalidity = vi.fn();
mount({ onvalidity });
await type('13:00 PM');
await expect.element(page.getByRole('alert')).toBeInTheDocument();
expect(onvalidity).toHaveBeenCalledWith(false);
});
it('offers the 5-minute clock options', async () => {
mount();
const options = document.querySelectorAll('datalist option');
expect(options).toHaveLength((24 * 60) / 5);
});
});
+19 -14
View File
@@ -17,8 +17,8 @@
deleteAction?: string; deleteAction?: string;
} = $props(); } = $props();
function handleBackdrop(e: MouseEvent) { function handleBackdrop() {
if (e.target === e.currentTarget) onClose?.(); onClose?.();
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
@@ -44,24 +44,27 @@
{#if open} {#if open}
<div <div
role="presentation" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-12"
onclick={handleBackdrop}
onkeydown={handleKeydown} onkeydown={handleKeydown}
role="presentation"
>
<div class="absolute inset-0" onclick={handleBackdrop} aria-hidden="true"></div>
<div
role="dialog"
aria-modal="true"
aria-label={title}
class="relative z-10 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
> >
<div <div
class="my-8 w-full max-w-2xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900" class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
> >
<div <h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">{title}</h2>
class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if canDelete && recordId} {#if canDelete && recordId}
<button <button
type="button" type="button"
onclick={confirmDelete} onclick={confirmDelete}
class="px-3 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
> >
Delete Delete
</button> </button>
@@ -69,12 +72,14 @@
<button <button
type="button" type="button"
onclick={onClose} onclick={onClose}
class="px-2 py-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" aria-label="Close"
>&times;</button class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
> >
&times;
</button>
</div> </div>
</div> </div>
<div class="px-6 py-4"> <div class="overflow-y-auto px-6 py-5">
{@render children()} {@render children()}
</div> </div>
</div> </div>
-248
View File
@@ -1,248 +0,0 @@
<script lang="ts">
import type { PrismaScalarType } from '$lib/svelte-admin/types';
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
let {
records = [],
listFields = [],
searchFields = [],
fieldTypes = {} as Record<string, PrismaScalarType>,
singular = 'record',
draggable = false,
copyableFields = [] as string[],
onRowClick,
onNew,
onReorder
}: {
records: Record<string, unknown>[];
listFields: string[];
searchFields?: string[];
fieldTypes?: Record<string, PrismaScalarType>;
singular?: string;
draggable?: boolean;
copyableFields?: string[];
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
let sortCol = $state<string | null>(null);
let sortAsc = $state(true);
let filtered = $derived(
search
? records.filter((r) =>
searchFields.some((f) => {
const v = resolveFieldValue(r, f);
return v != null && String(v).toLowerCase().includes(search.toLowerCase());
})
)
: records
);
let sorted = $derived(
sortCol
? [...filtered].sort((a, b) => {
const va = resolveFieldValue(a, sortCol!);
const vb = resolveFieldValue(b, sortCol!);
if (va == null) return 1;
if (vb == null) return -1;
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortAsc ? cmp : -cmp;
})
: filtered
);
let isDragging = $state(false);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let rows = $derived(dragOverride ?? sorted);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
function toggleSort(field: string) {
if (sortCol === field) {
sortAsc = !sortAsc;
} else {
sortCol = field;
sortAsc = true;
}
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = sorted;
isDragging = true;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function copyValue(v: unknown) {
navigator.clipboard.writeText(String(v ?? ''));
}
function formatCellValue(v: unknown, field: string): string {
if (v == null) return '';
const ft = fieldTypes[field];
if (ft === 'DateTime') {
if (v instanceof Date) return v.toLocaleString();
return new Date(v as string).toLocaleString();
}
if (ft === 'Boolean') return v ? 'Active' : 'Inactive';
if (ft === 'Float') {
const n = Number(v);
return Number.isNaN(n) ? String(v) : n.toFixed(2);
}
return String(v);
}
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
{#if searchFields.length > 0}
<input
type="text"
placeholder="Search {singular}s..."
bind:value={search}
class="max-w-xs border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
{:else}
<div></div>
{/if}
{#if onNew}
<button
type="button"
onclick={onNew}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
New {singular} +
</button>
{/if}
</div>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
{#if draggable}
<th scope="col" class="w-10 px-2 py-2"></th>
{/if}
{#each listFields as field (field)}
<th
scope="col"
onclick={() => toggleSort(field)}
class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
{fieldNameToLabel(field)}
{#if sortCol === field}
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
<tr
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
class="border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 {draggable &&
isDragging &&
dragIndex === i
? 'opacity-40'
: ''} {draggable && isDragging && hoverIndex === i
? 'border-t-2 border-t-blue-500'
: ''}"
>
{#if draggable}
<td
class="w-10 cursor-grab px-2 py-2 text-center"
onclick={(e) => e.stopPropagation()}
>
<span class="text-gray-400 select-none">⠿</span>
</td>
{/if}
{#each listFields as field (field)}
<td class="px-4 py-2 text-sm">
<span class="inline-flex items-center gap-1.5">
{formatCellValue(resolveFieldValue(record, field), field)}
{#if copyableFields.includes(field)}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(resolveFieldValue(record, field));
}}
title="Copy to clipboard"
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
class="size-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
{/if}
</span>
</td>
{/each}
</tr>
{:else}
<tr>
<td
colspan={listFields.length + (draggable ? 1 : 0)}
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No {singular}s found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
+11 -15
View File
@@ -1,11 +1,5 @@
<script lang="ts"> <script lang="ts">
let { let { message = '' }: { message?: string } = $props();
message = '',
type = 'success' as 'success' | 'error'
}: {
message?: string;
type?: 'success' | 'error';
} = $props();
let remaining = $state(5); let remaining = $state(5);
let visible = $state(true); let visible = $state(true);
@@ -32,15 +26,17 @@
{#if visible} {#if visible}
<div <div
role="alert" role="alert"
class="fixed bottom-4 left-4 z-50 flex min-w-64 items-center gap-3 border px-4 py-3 text-sm shadow-lg {type === class="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-white shadow-lg dark:border-slate-300 dark:bg-slate-950"
'success'
? 'border-green-600 bg-green-50 text-green-800'
: 'border-red-600 bg-red-50 text-red-800'}"
> >
<span class="flex-1">{message}</span> <span>{message}</span>
<span class="text-xs tabular-nums opacity-70">{remaining}s</span> <span class="text-xs text-slate-400 dark:text-slate-500">{remaining}s</span>
<button type="button" onclick={dismiss} class="ml-1 leading-none font-bold hover:opacity-70" <button
>&times;</button type="button"
onclick={dismiss}
aria-label="Dismiss"
class="rounded p-1 text-slate-400 transition hover:text-white dark:text-slate-500"
> >
&times;
</button>
</div> </div>
{/if} {/if}
+5 -1
View File
@@ -2,6 +2,10 @@
let { children } = $props(); let { children } = $props();
</script> </script>
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto"> <div class="mx-auto w-full max-w-md px-4 py-8 sm:py-12">
<div
class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900"
>
{@render children()} {@render children()}
</div> </div>
</div>
@@ -0,0 +1,369 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import TimeInput from '$lib/components/TimeInput.svelte';
import {
formatTime,
hmToMinutes,
isOvernight,
toHm,
validateBlockAgainstSchedule,
type BlockSibling
} from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
day = null,
licenseKeyId = '',
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = true,
scheduleType = 'daily' as 'daily' | 'weekly' | null,
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
day?: string | null;
licenseKeyId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
scheduleType?: 'daily' | 'weekly' | null;
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
// Seeded from the props in the effect below whenever the modal opens.
let startMinutes = $state(9 * 60);
let endMinutes = $state(10 * 60);
let startValid = $state(true);
let endValid = $state(true);
let selectedIds = $state<string[]>([]);
let dynamicAudio = $state(true);
let error = $state('');
function setStart(hm: string) {
const minutes = hmToMinutes(hm);
if (minutes !== null) startMinutes = minutes;
}
function setEnd(hm: string) {
const minutes = hmToMinutes(hm);
if (minutes !== null) endMinutes = minutes;
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
startMinutes = initialStartMinutes;
endMinutes = initialEndMinutes;
startValid = true;
endValid = true;
selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = '';
step = 'details';
});
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
if (!startValid || !endValid) return 'Enter a valid start and end time.';
if (scheduleType === null) return 'Set a schedule type first.';
const result = validateBlockAgainstSchedule(
scheduleType,
{ day, startMinutes, endMinutes, proclamationIds: selectedIds },
siblings
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="licenseKeyId" value={licenseKeyId} />
{#if day}
<input type="hidden" name="day" value={day} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<TimeInput
label="Start time"
value={toHm(startMinutes)}
onchange={setStart}
onvalidity={(valid) => (startValid = valid)}
/>
<TimeInput
label="End time"
value={toHm(endMinutes)}
onchange={setEnd}
onvalidity={(valid) => (endValid = valid)}
/>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
>
<input
type="checkbox"
checked={dynamicAudio}
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
Enable Dynamic Audio
</label>
<span class="group relative inline-flex">
<button
type="button"
aria-label="What is Dynamic Audio?"
aria-describedby="dynamic-audio-help"
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
>
?
</button>
<span
id="dynamic-audio-help"
role="tooltip"
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
>
Dynamic audio will be enabled for this block, while this block is playing.
This will override any audio selections you have made on your device while
this block is active.
</span>
</span>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (ends next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div class="flex items-center gap-3">
{#if step === 'details' && mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
</div>
<div class="flex items-center gap-3">
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Accept
</button>
{:else}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,198 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
async function typeTime(name: string, text: string) {
const input = (await page.getByRole('combobox', { name }).element()) as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('BlockModal', () => {
it('uses a 12-hour TimeInput for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'End time' })).toBeInTheDocument();
});
it('pre-fills the fields from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('7:30 AM');
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('9:15 AM');
});
it('shows a 12-hour clock with AM/PM', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('9:00 PM');
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('12:00 AM');
});
it('recomputes the time from typed input', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
await typeTime('End time', '10:00 PM');
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
});
it('shows validation feedback for unparseable input', async () => {
mount();
await typeTime('Start time', '25:00');
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (ends next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('shows Cancel with Accept in the browse step, and Accept returns to the details', async () => {
mount();
const browse = page.getByRole('button', { name: 'Browse proclamations' });
await expect.element(browse).toBeInTheDocument();
(browse.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const accept = page.getByRole('button', { name: 'Accept' });
await expect.element(accept).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Block' }))
.not.toBeInTheDocument();
(accept.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
});
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
await expect
.element(page.getByText(/Dynamic audio will be enabled for this block/))
.toBeInTheDocument();
});
it('defaults dynamic audio on for new blocks', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
mount({ initialDynamicAudio: true });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('leaves dynamic audio off when editing a block that has it disabled', async () => {
mount({ initialDynamicAudio: false });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
});
it('toggles the dynamic audio flag in the submitted payload', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('false');
});
});
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});
@@ -0,0 +1,287 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
day?: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
type OpenBlock = {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
let {
days,
blocks = [],
onCreate,
onEdit
}: {
days: { key: string; label: string; short?: string }[];
blocks?: ScheduleBlockView[];
onCreate?: (day: string, startMinutes: number, endMinutes: number) => void;
onEdit?: (block: OpenBlock) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
// Which grid the pointer is currently over, so the live preview shows up in
// the right day column (Google-Calendar style).
let drag = $state<{
day: string;
anchor: number;
current: number;
pointerId: number;
} | null>(null);
function dayLabel(key: string): string {
return days.find((d) => d.key === key)?.label ?? key;
}
function minutesFromEvent(event: PointerEvent, column: HTMLElement): number {
const rect = column.getBoundingClientRect();
// Guard against an unmeasured/zero-height column (e.g. before layout) so
// a tap still yields a valid, finite start minute instead of NaN.
if (!rect.height) return 0;
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
// Pointer capture happens on the column element, so moves/ups land there even
// when the pointer leaves the small cell.
function startDrag(event: PointerEvent, day: string) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const column = event.currentTarget as HTMLElement;
const minutes = minutesFromEvent(event, column);
drag = { day, anchor: minutes, current: minutes, pointerId: event.pointerId };
column.setPointerCapture(event.pointerId);
event.preventDefault();
}
function moveDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const column = event.currentTarget as HTMLElement;
drag = { ...drag, current: minutesFromEvent(event, column) };
}
function endDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const { day, anchor, current } = drag;
const column = event.currentTarget as HTMLElement;
column.releasePointerCapture?.(event.pointerId);
drag = null;
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(day, start, end);
return;
}
// A plain click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.min(MINUTES_PER_DAY, clickStart + defaultDuration);
onCreate?.(day, Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
day: drag.day,
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
function blocksForDay(day: string): ScheduleBlockView[] {
// Daily schedules use a single "daily" column while their Blocks carry
// day=null; weekly columns match the `mon`..`sun` keys directly.
return blocks.filter((b) => (day === 'daily' ? b.day == null : b.day === day));
}
// The column before each day, wrapping across the schedule (mon's previous
// day is sun; the single daily column is its own previous day). Used to spill
// an overnight block's post-midnight tail into the next day's column.
const previousDayKey: Record<string, string> = $derived.by(() => {
const map: Record<string, string> = {};
days.forEach((day, index) => {
map[day.key] = days[(index - 1 + days.length) % days.length].key;
});
return map;
});
type DayView = {
block: ScheduleBlockView;
// A same-day block, the pre-midnight start of an overnight block, or the
// post-midnight tail of the previous day's overnight block.
part: 'whole' | 'head' | 'tail';
top: number;
height: number;
};
function toView(
block: ScheduleBlockView,
part: DayView['part'],
[start, end]: [number, number]
): DayView {
return {
block,
part,
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
}
// A column shows its own blocks plus the tails of the previous day's
// overnight blocks. An overnight block contributes only its pre-midnight
// head to its start day; its tail is drawn at the top of the next day
// (weekly sun 7PM→1AM spills into mon). For daily, the previous day is the
// same single column, so both parts render together as before.
function viewsForDay(day: string): DayView[] {
const views: DayView[] = [];
for (const block of blocksForDay(day)) {
const head = blockSegments(block)[0];
if (!head || head[1] <= head[0]) continue;
views.push(toView(block, isOvernight(block) ? 'head' : 'whole', head));
}
const previous = previousDayKey[day];
if (previous) {
for (const block of blocksForDay(previous)) {
if (!isOvernight(block)) continue;
const tail = blockSegments(block)[1];
if (!tail || tail[1] <= tail[0]) continue;
views.push(toView(block, 'tail', tail));
}
}
return views;
}
</script>
<!--
Schedule as a Google-Calendar-style grid: day columns side by side with a
shared time gutter. Scrolls horizontally on narrow screens (the gutter stays
pinned); tap/click or drag an empty slot to create a block at that time, tap a
block to edit it.
-->
<div
class="isolate overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex overflow-x-auto" style="overscroll-behavior-x: contain;">
<!-- Time gutter (pinned while the day columns scroll horizontally). -->
<div
class="sticky left-0 z-30 w-20 shrink-0 border-r border-slate-200 bg-white sm:w-24 dark:border-slate-800 dark:bg-slate-900"
>
<!-- Spacer aligning with the day header row. -->
<div class="h-12 border-b border-slate-200 dark:border-slate-800"></div>
<div class="relative h-[720px]" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-2 -translate-y-1/2 text-xs whitespace-nowrap text-slate-400 tabular-nums dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{formatTime(hour * 60)}
</div>
{/each}
</div>
</div>
<!-- Day columns share the available width equally so a weekly view fits
without scrolling on desktop. Each keeps a minimum width, so when the
seven days cannot fit (phones) the row scrolls horizontally. -->
{#each days as day (day.key)}
<div
class="min-w-24 flex-1 basis-0 border-r border-slate-200 last:border-r-0 dark:border-slate-800"
>
<div
class="flex h-12 items-center justify-center border-b border-slate-200 px-1 text-xs font-semibold text-slate-700 sm:text-sm dark:border-slate-800 dark:text-slate-200"
>
{#if day.short}
<span class="sm:hidden">{day.short}</span>
<span class="hidden sm:inline">{day.label}</span>
{:else}
{day.label}
{/if}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-[720px] cursor-pointer select-none"
style="touch-action: pan-x pan-y;"
aria-label={`Schedule grid ${dayLabel(day.key)}`}
onpointerdown={(e) => startDrag(e, day.key)}
onpointermove={moveDrag}
onpointerup={endDrag}
onpointercancel={(e) => {
if (drag && e.pointerId === drag.pointerId) drag = null;
}}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview && preview.day === day.key}
<div
class="pointer-events-none absolute inset-x-1 z-10 rounded-md bg-indigo-200/70 dark:bg-indigo-700/50"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.({ ...view.block, day: view.block.day ?? null });
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-0.5 z-20 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-1.5 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/70 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${view.top}%;height:${view.height}%`}
>
{#if view.part === 'tail'}
<span class="block truncate font-semibold tabular-nums">
{formatTime(0)}–{formatTime(view.block.endMinutes)}
</span>
<span class="block truncate text-[11px] opacity-80">↳ continues</span>
{:else}
<span class="block truncate font-semibold tabular-nums">
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
</span>
{#if view.part === 'head'}
<span class="block truncate text-[11px] opacity-80">ends next day</span>
{/if}
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
@@ -0,0 +1,172 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import WeekSchedule from './WeekSchedule.svelte';
const WEEK_DAYS = [
{ key: 'mon', label: 'Monday' },
{ key: 'tue', label: 'Tuesday' },
{ key: 'wed', label: 'Wednesday' },
{ key: 'thu', label: 'Thursday' },
{ key: 'fri', label: 'Friday' },
{ key: 'sat', label: 'Saturday' },
{ key: 'sun', label: 'Sunday' }
];
const block = (overrides: Record<string, unknown> = {}) => ({
id: 'b1',
day: 'mon',
startMinutes: 6 * 60,
endMinutes: 8 * 60,
dynamicAudio: true,
proclamations: [{ id: 'p1', name: 'Peace' }],
...overrides
});
function mount(props: Record<string, unknown> = {}) {
return render(WeekSchedule, { days: WEEK_DAYS, blocks: [], ...props });
}
// Click at a fraction of the way down a day column.
async function clickColumn(label: string, fraction: number) {
const column = document.querySelector(
`[aria-label="Schedule grid ${label}"]`
) as HTMLElement | null;
if (!column) throw new Error(`no column ${label}`);
const rect = column.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height * fraction;
column.dispatchEvent(
new PointerEvent('pointerdown', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
);
column.dispatchEvent(
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
);
}
describe('WeekSchedule', () => {
it('renders all seven day columns side by side', async () => {
mount();
for (const day of WEEK_DAYS) {
await expect.element(page.getByText(day.label)).toBeInTheDocument();
}
});
it('renders a single column for a daily schedule', async () => {
mount({ days: [{ key: 'daily', label: 'Every day' }] });
await expect.element(page.getByText('Every day')).toBeInTheDocument();
expect(document.querySelectorAll('[aria-label^="Schedule grid"]')).toHaveLength(1);
});
it('is horizontally scrollable with a pinned time gutter (mobile-friendly)', async () => {
mount();
// The day columns live in a horizontally scrollable row.
const scroller = document.querySelector('.overflow-x-auto') as HTMLElement | null;
expect(scroller).not.toBeNull();
// The time gutter is sticky so it stays visible while days scroll.
const gutter = scroller?.querySelector('[class*="sticky"][class*="left-0"]');
expect(gutter).not.toBeNull();
// Each day column has a minimum basis width so it stays legible on a phone.
const columns = scroller?.querySelectorAll('[aria-label^="Schedule grid"]') ?? [];
expect(columns.length).toBe(7);
});
it('shows short day labels on small screens', async () => {
mount({
days: [
{ key: 'mon', label: 'Monday', short: 'Mon' },
{ key: 'tue', label: 'Tuesday', short: 'Tue' }
]
});
// Both the short (small-screen) and long (large-screen) labels exist in
// the DOM; CSS decides which is visible at a given width.
const text = document.body.textContent ?? '';
expect(text).toContain('Mon');
expect(text).toContain('Monday');
});
it('shows blocks as events', async () => {
mount({ blocks: [block()] });
await expect.element(page.getByText('Peace')).toBeInTheDocument();
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
});
it('renders daily blocks (day null) in the single daily column', async () => {
mount({
days: [{ key: 'daily', label: 'Every day' }],
blocks: [block({ day: null })]
});
await expect.element(page.getByText('Peace')).toBeInTheDocument();
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
});
function column(label: string): HTMLElement {
return document.querySelector(`[aria-label="Schedule grid ${label}"]`) as HTMLElement;
}
it('spills an overnight weekly block into the next day column', async () => {
mount({ blocks: [block({ day: 'sun', startMinutes: 19 * 60, endMinutes: 60 })] });
// The start day keeps only the pre-midnight head.
const sunday = column('Sunday');
expect(sunday.textContent).toContain('7:00 PM–1:00 AM');
expect(sunday.textContent).toContain('ends next day');
expect(sunday.textContent).not.toContain('continues');
// The post-midnight tail is drawn on the following day.
const monday = column('Monday');
expect(monday.textContent).toContain('12:00 AM–1:00 AM');
expect(monday.textContent).toContain('↳ continues');
});
it('spills a Saturday overnight block into Sunday', async () => {
mount({ blocks: [block({ day: 'sat', startMinutes: 23 * 60, endMinutes: 30 })] });
const sunday = column('Sunday');
expect(sunday.textContent).toContain('12:00 AM–12:30 AM');
expect(sunday.textContent).toContain('↳ continues');
});
it('keeps both parts of a daily overnight block in its single column', async () => {
mount({
days: [{ key: 'daily', label: 'Every day' }],
blocks: [block({ day: null, startMinutes: 21 * 60, endMinutes: 9 * 60 })]
});
const daily = column('Every day');
expect(daily.textContent).toContain('9:00 PM–9:00 AM');
expect(daily.textContent).toContain('ends next day');
expect(daily.textContent).toContain('12:00 AM–9:00 AM');
expect(daily.textContent).toContain('↳ continues');
});
it('opens create on the clicked day', async () => {
const onCreate = vi.fn();
mount({ onCreate });
await clickColumn('Monday', 0.25);
expect(onCreate).toHaveBeenCalledTimes(1);
const [day, start, end] = onCreate.mock.calls[0] as [string, number, number];
expect(day).toBe('mon');
// The handler always yields a sensible minute range (a default-length
// block when the click distance is below the snap threshold).
expect(Number.isFinite(start)).toBe(true);
expect(Number.isFinite(end)).toBe(true);
expect(end - start).toBeGreaterThan(0);
});
it('opens edit when a block is clicked', async () => {
const onEdit = vi.fn();
mount({ blocks: [block()], onEdit });
await page.getByLabelText('Block 6:00 AM to 8:00 AM').click();
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'b1', day: 'mon' });
});
});
-103
View File
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
let { initialContent = '', name = 'content' }: { initialContent?: string; name?: string } =
$props();
let element: HTMLElement;
let editor: Editor;
let content = $state('');
onMount(() => {
content = initialContent;
editor = new Editor({
element,
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false
}),
TextStyle,
Color,
Highlight.configure({ multicolor: true })
],
content: initialContent,
editorProps: {
attributes: {
class:
'prose prose-sm max-w-none outline-none h-full min-h-40 p-3 dark:prose-invert dark:text-gray-100'
}
},
onUpdate: ({ editor }) => {
content = editor.getHTML();
}
});
editor.setEditable(true);
return () => editor.destroy();
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleHighlight() {
editor?.chain().focus().toggleHighlight().run();
}
function setColor() {
const current = editor?.getAttributes('textStyle').color ?? '#000000';
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
const idx = colors.indexOf(current);
const next = colors[(idx + 1) % colors.length];
editor?.chain().focus().setColor(next).run();
}
</script>
<div class="border border-gray-300 dark:border-gray-600">
<div
class="flex gap-1 border-b border-gray-300 bg-gray-100 p-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<button
type="button"
onclick={toggleBold}
class="min-w-8 px-2 py-1 font-bold hover:bg-gray-200 dark:hover:bg-gray-700"
title="Bold">B</button
>
<button
type="button"
onclick={toggleItalic}
class="min-w-8 px-2 py-1 italic hover:bg-gray-200 dark:hover:bg-gray-700"
title="Italic">I</button
>
<button
type="button"
onclick={toggleHighlight}
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Highlight"><span class="bg-yellow-200 px-1 dark:bg-yellow-600">H</span></button
>
<button
type="button"
onclick={setColor}
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Cycle text color">A</button
>
</div>
<div
bind:this={element}
role="textbox"
tabindex="0"
contenteditable="true"
class="min-h-40 cursor-text bg-white dark:bg-gray-900"
></div>
</div>
<input type="hidden" {name} value={content} />
+160 -6
View File
@@ -2,18 +2,172 @@ import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '$lib/generated/prisma/client'; import { PrismaClient } from '$lib/generated/prisma/client';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { getAuditContext } from '$lib/server/audit-context';
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from '$lib/server/audit';
let _prisma: PrismaClient | undefined; let _prisma: PrismaClient | undefined;
let _base: PrismaClient | undefined;
let _pool: pg.Pool | undefined;
function getBase(): { base: PrismaClient; pool: pg.Pool } {
if (!_base || !_pool) {
_pool = new pg.Pool({ connectionString: env.DATABASE_URL });
const adapter = new PrismaPg(_pool);
_base = new PrismaClient({ adapter });
}
return { base: _base, pool: _pool };
}
const roleNameCache = new Map<number, string>();
async function resolveRoleName(base: PrismaClient, roleId: number | null): Promise<string | null> {
if (roleId === null) return null;
const cached = roleNameCache.get(roleId);
if (cached) return cached;
try {
const role = await base.userRole.findUnique({
where: { id: roleId },
select: { name: true }
});
if (role?.name) {
roleNameCache.set(roleId, role.name);
return role.name;
}
} catch (e) {
console.error('Failed to resolve role name for audit log:', e);
}
return null;
}
type Delegate = {
findFirst?: (args: unknown) => Promise<unknown>;
findMany?: (args: unknown) => Promise<unknown>;
};
function delegateFor(base: PrismaClient, model: string): Delegate | undefined {
const key = model.charAt(0).toLowerCase() + model.slice(1);
return (base as unknown as Record<string, unknown>)[key] as Delegate | undefined;
}
async function readBefore(
base: PrismaClient,
model: string,
operation: string,
args: unknown
): Promise<unknown> {
if (operation.startsWith('create')) return null;
const delegate = delegateFor(base, model);
if (!delegate) return null;
const where = (args as { where?: unknown } | null)?.where;
try {
if (operation === 'updateMany' || operation === 'deleteMany') {
return delegate.findMany ? await delegate.findMany({ where, take: 50 }) : null;
}
return delegate.findFirst ? await delegate.findFirst({ where }) : null;
} catch {
return null;
}
}
async function writeAuditRow(
pool: pg.Pool,
base: PrismaClient,
entry: {
userId: string | null;
email: string | null;
roleId: number | null;
page: string;
action: string;
entity: string;
entityId: string | null;
before: unknown;
after: unknown;
ip: string | null;
}
): Promise<void> {
const roleName = await resolveRoleName(base, entry.roleId);
await pool.query(
`INSERT INTO "AuditLog"
("id", "createdAt", "userId", "email", "roleId", "roleName", "page", "action", "entity", "entityId", "before", "after", "ip")
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12)`,
[
crypto.randomUUID(),
entry.userId,
entry.email,
entry.roleId,
roleName,
entry.page,
entry.action,
entry.entity,
entry.entityId,
entry.before === null ? null : JSON.stringify(entry.before),
entry.after === null ? null : JSON.stringify(entry.after),
entry.ip
]
);
}
function getPrisma(): PrismaClient { function getPrisma(): PrismaClient {
if (!_prisma) { if (_prisma) return _prisma;
const pool = new pg.Pool({
connectionString: env.DATABASE_URL const { base, pool } = getBase();
const extended = base.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!shouldAudit(model, operation)) {
return query(args);
}
const before = await readBefore(base, model, operation, args);
const result = await query(args);
try {
const ctx = getAuditContext();
const res = result as { id?: unknown; email?: string; roleId?: number | null } | null;
let userId = ctx?.userId ?? null;
let email = ctx?.email ?? null;
let roleId = ctx?.roleId ?? null;
// Self-signup: attribute the new user to itself since no session exists yet.
if (!userId && model === 'User' && operation === 'create' && res) {
userId = (res.id as string | undefined) ?? null;
email = res.email ?? null;
roleId = res.roleId ?? null;
}
const after =
operation === 'updateMany' || operation === 'deleteMany'
? { count: (result as { count?: number } | null)?.count ?? null }
: operation.startsWith('delete')
? null
: result;
await writeAuditRow(pool, base, {
userId,
email,
roleId,
page: ctx?.page ?? '/system',
action: describeAction(operation),
entity: model,
entityId: entityIdFor(args, result, before),
before: sanitizeAuditValue(before),
after: sanitizeAuditValue(after),
ip: ctx?.ip ?? null
});
} catch (e) {
console.error('Failed to write audit log:', e);
}
return result;
}
}
}
}); });
const adapter = new PrismaPg(pool); _prisma = extended as unknown as PrismaClient;
_prisma = new PrismaClient({ adapter });
}
return _prisma; return _prisma;
} }
+44 -24
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
@@ -12,83 +13,102 @@
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
import * as Prisma from './internal/prismaNamespaceBrowser.ts'; import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }; export { Prisma }
export * as $Enums from './enums.ts'; export * as $Enums from './enums.ts'
export * from './enums.ts'; export * from './enums.ts';
/** /**
* Model User * Model User
* *
*/ */
export type User = Prisma.UserModel; export type User = Prisma.UserModel
/** /**
* Model Profile * Model Profile
* *
*/ */
export type Profile = Prisma.ProfileModel; export type Profile = Prisma.ProfileModel
/** /**
* Model Address * Model Address
* *
*/ */
export type Address = Prisma.AddressModel; export type Address = Prisma.AddressModel
/** /**
* Model Session * Model Session
* *
*/ */
export type Session = Prisma.SessionModel; export type Session = Prisma.SessionModel
/** /**
* Model Account * Model Account
* *
*/ */
export type Account = Prisma.AccountModel; export type Account = Prisma.AccountModel
/** /**
* Model UserRole * Model UserRole
* *
*/ */
export type UserRole = Prisma.UserRoleModel; export type UserRole = Prisma.UserRoleModel
/** /**
* Model Verification * Model Verification
* *
*/ */
export type Verification = Prisma.VerificationModel; export type Verification = Prisma.VerificationModel
/** /**
* Model Proclamation * Model Proclamation
* *
*/ */
export type Proclamation = Prisma.ProclamationModel; export type Proclamation = Prisma.ProclamationModel
/**
* Model ProclamationGroup
*
*/
export type ProclamationGroup = Prisma.ProclamationGroupModel
/** /**
* Model UserProclamations * Model UserProclamations
* *
*/ */
export type UserProclamations = Prisma.UserProclamationsModel; export type UserProclamations = Prisma.UserProclamationsModel
/** /**
* Model SolfeggioFrequency * Model SolfeggioFrequency
* *
*/ */
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel; export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
/** /**
* Model ProclamationCategory * Model ProclamationCategory
* *
*/ */
export type ProclamationCategory = Prisma.ProclamationCategoryModel; export type ProclamationCategory = Prisma.ProclamationCategoryModel
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel =
Prisma.ProclamationCategorySubscriptionLevelModel;
/** /**
* Model LicenseKey * Model LicenseKey
* *
*/ */
export type LicenseKey = Prisma.LicenseKeyModel; export type LicenseKey = Prisma.LicenseKeyModel
/** /**
* Model ApiKey * Model ApiKey
* *
*/ */
export type ApiKey = Prisma.ApiKeyModel; export type ApiKey = Prisma.ApiKeyModel
/** /**
* Model SubscriptionLevel * Model ColorPalette
* *
*/ */
export type SubscriptionLevel = Prisma.SubscriptionLevelModel; export type ColorPalette = Prisma.ColorPaletteModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
+54 -38
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
@@ -9,18 +10,18 @@
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
import * as process from 'node:process'; import * as process from 'node:process'
import * as path from 'node:path'; import * as path from 'node:path'
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url)); globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from '@prisma/client/runtime/client'; import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from './enums.ts'; import * as $Enums from "./enums.ts"
import * as $Class from './internal/class.ts'; import * as $Class from "./internal/class.ts"
import * as Prisma from './internal/prismaNamespace.ts'; import * as Prisma from "./internal/prismaNamespace.ts"
export * as $Enums from './enums.ts'; export * as $Enums from './enums.ts'
export * from './enums.ts'; export * from "./enums.ts"
/** /**
* ## Prisma Client * ## Prisma Client
* *
@@ -36,87 +37,102 @@ export * from './enums.ts';
* *
* Read more in our [docs](https://pris.ly/d/client). * Read more in our [docs](https://pris.ly/d/client).
*/ */
export const PrismaClient = $Class.getPrismaClientClass(); export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient< export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
LogOpts extends Prisma.LogLevel = never, export { Prisma }
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
export { Prisma };
/** /**
* Model User * Model User
* *
*/ */
export type User = Prisma.UserModel; export type User = Prisma.UserModel
/** /**
* Model Profile * Model Profile
* *
*/ */
export type Profile = Prisma.ProfileModel; export type Profile = Prisma.ProfileModel
/** /**
* Model Address * Model Address
* *
*/ */
export type Address = Prisma.AddressModel; export type Address = Prisma.AddressModel
/** /**
* Model Session * Model Session
* *
*/ */
export type Session = Prisma.SessionModel; export type Session = Prisma.SessionModel
/** /**
* Model Account * Model Account
* *
*/ */
export type Account = Prisma.AccountModel; export type Account = Prisma.AccountModel
/** /**
* Model UserRole * Model UserRole
* *
*/ */
export type UserRole = Prisma.UserRoleModel; export type UserRole = Prisma.UserRoleModel
/** /**
* Model Verification * Model Verification
* *
*/ */
export type Verification = Prisma.VerificationModel; export type Verification = Prisma.VerificationModel
/** /**
* Model Proclamation * Model Proclamation
* *
*/ */
export type Proclamation = Prisma.ProclamationModel; export type Proclamation = Prisma.ProclamationModel
/**
* Model ProclamationGroup
*
*/
export type ProclamationGroup = Prisma.ProclamationGroupModel
/** /**
* Model UserProclamations * Model UserProclamations
* *
*/ */
export type UserProclamations = Prisma.UserProclamationsModel; export type UserProclamations = Prisma.UserProclamationsModel
/** /**
* Model SolfeggioFrequency * Model SolfeggioFrequency
* *
*/ */
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel; export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
/** /**
* Model ProclamationCategory * Model ProclamationCategory
* *
*/ */
export type ProclamationCategory = Prisma.ProclamationCategoryModel; export type ProclamationCategory = Prisma.ProclamationCategoryModel
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel =
Prisma.ProclamationCategorySubscriptionLevelModel;
/** /**
* Model LicenseKey * Model LicenseKey
* *
*/ */
export type LicenseKey = Prisma.LicenseKeyModel; export type LicenseKey = Prisma.LicenseKeyModel
/** /**
* Model ApiKey * Model ApiKey
* *
*/ */
export type ApiKey = Prisma.ApiKeyModel; export type ApiKey = Prisma.ApiKeyModel
/** /**
* Model SubscriptionLevel * Model ColorPalette
* *
*/ */
export type SubscriptionLevel = Prisma.SubscriptionLevelModel; export type ColorPalette = Prisma.ColorPaletteModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
+484 -330
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
@@ -8,395 +9,548 @@
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
import type * as runtime from '@prisma/client/runtime/client'; import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from './enums.ts'; import * as $Enums from "./enums.ts"
import type * as Prisma from './internal/prismaNamespace.ts'; import type * as Prisma from "./internal/prismaNamespace.ts"
export type StringFilter<$PrismaModel = never> = { export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>; equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode; mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string; not?: Prisma.NestedStringFilter<$PrismaModel> | string
}; }
export type BoolFilter<$PrismaModel = never> = { export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>; equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean; not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}; }
export type StringNullableFilter<$PrismaModel = never> = { export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null; equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode; mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null; not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}; }
export type DateTimeFilter<$PrismaModel = never> = { export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string; not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}; }
export type IntNullableFilter<$PrismaModel = never> = { export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null; equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null; not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}; }
export type SortOrderInput = { export type SortOrderInput = {
sort: Prisma.SortOrder; sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder; nulls?: Prisma.NullsOrder
}; }
export type StringWithAggregatesFilter<$PrismaModel = never> = { export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>; equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode; mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string; not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>; _min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>; _max?: Prisma.NestedStringFilter<$PrismaModel>
}; }
export type BoolWithAggregatesFilter<$PrismaModel = never> = { export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>; equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean; not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>; _min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>; _max?: Prisma.NestedBoolFilter<$PrismaModel>
}; }
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = { export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null; equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode; mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null; not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>; _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>; _min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>; _max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}; }
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = { export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string; not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>; _min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>; _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}; }
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = { export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null; equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null; not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>; _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>; _avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>; _sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>; _min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>; _max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}; }
export type DateTimeNullableFilter<$PrismaModel = never> = { export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null; not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}; }
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null; not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>; _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>; _min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>; _max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}; }
export type IntFilter<$PrismaModel = never> = { export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>; equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number; not?: Prisma.NestedIntFilter<$PrismaModel> | number
}; }
export type IntWithAggregatesFilter<$PrismaModel = never> = { export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>; equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number; not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>; _avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>; _sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>; _min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>; _max?: Prisma.NestedIntFilter<$PrismaModel>
}; }
export type JsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
export type JsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type JsonWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedJsonFilter<$PrismaModel>
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type JsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = { export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>; equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string; not?: Prisma.NestedStringFilter<$PrismaModel> | string
}; }
export type NestedBoolFilter<$PrismaModel = never> = { export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>; equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean; not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}; }
export type NestedStringNullableFilter<$PrismaModel = never> = { export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null; equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null; not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}; }
export type NestedDateTimeFilter<$PrismaModel = never> = { export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string; not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}; }
export type NestedIntNullableFilter<$PrismaModel = never> = { export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null; equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null; not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}; }
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = { export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>; equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string; not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>; _min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>; _max?: Prisma.NestedStringFilter<$PrismaModel>
}; }
export type NestedIntFilter<$PrismaModel = never> = { export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>; equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number; not?: Prisma.NestedIntFilter<$PrismaModel> | number
}; }
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = { export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>; equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean; not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>; _min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>; _max?: Prisma.NestedBoolFilter<$PrismaModel>
}; }
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = { export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null; equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null; notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>; lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>; lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>; gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>; gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>; contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>; endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null; not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>; _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>; _min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>; _max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}; }
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = { export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string; not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>; _min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>; _max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}; }
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = { export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null; equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null; not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>; _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>; _avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>; _sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>; _min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>; _max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}; }
export type NestedFloatNullableFilter<$PrismaModel = never> = { export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null; equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null; in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null; notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>; lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>; lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>; gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>; gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null; not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}; }
export type NestedDateTimeNullableFilter<$PrismaModel = never> = { export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null; not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}; }
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null; equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null; notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>; gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null; not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>; _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>; _min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>; _max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}; }
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = { export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>; equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>; notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>; lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>; lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>; gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>; gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number; not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>; _count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>; _avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>; _sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>; _min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>; _max?: Prisma.NestedIntFilter<$PrismaModel>
}; }
export type NestedFloatFilter<$PrismaModel = never> = { export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>; equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>; in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>; notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>; lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>; lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>; gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>; gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number; not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}; }
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedJsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
+4 -1
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
@@ -8,5 +9,7 @@
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
// This file is empty because there are no enums in the schema. // This file is empty because there are no enums in the schema.
export {}; export {}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
@@ -14,38 +15,40 @@
* model files in the `model` directory! * model files in the `model` directory!
*/ */
import * as runtime from '@prisma/client/runtime/index-browser'; import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.ts'; export type * from '../models.ts'
export type * from './prismaNamespace.ts'; export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export const Decimal = runtime.Decimal;
export const NullTypes = { export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as new (secret: never) => typeof runtime.DbNull, DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as new (secret: never) => typeof runtime.JsonNull, JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as new (secret: never) => typeof runtime.AnyNull AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}; }
/** /**
* Helper for filtering JSON entries that have `null` on the database (empty on the db) * Helper for filtering JSON entries that have `null` on the database (empty on the db)
* *
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/ */
export const DbNull = runtime.DbNull; export const DbNull = runtime.DbNull
/** /**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db) * Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
* *
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/ */
export const JsonNull = runtime.JsonNull; export const JsonNull = runtime.JsonNull
/** /**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull` * Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
* *
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/ */
export const AnyNull = runtime.AnyNull; export const AnyNull = runtime.AnyNull
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
@@ -56,16 +59,20 @@ export const ModelName = {
UserRole: 'UserRole', UserRole: 'UserRole',
Verification: 'Verification', Verification: 'Verification',
Proclamation: 'Proclamation', Proclamation: 'Proclamation',
ProclamationGroup: 'ProclamationGroup',
UserProclamations: 'UserProclamations', UserProclamations: 'UserProclamations',
SolfeggioFrequency: 'SolfeggioFrequency', SolfeggioFrequency: 'SolfeggioFrequency',
ProclamationCategory: 'ProclamationCategory', ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel' ColorPalette: 'ColorPalette',
} as const; AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]; export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/* /*
* Enums * Enums
@@ -76,10 +83,10 @@ export const TransactionIsolationLevel = runtime.makeStrictEnum({
ReadCommitted: 'ReadCommitted', ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead', RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable' Serializable: 'Serializable'
} as const); } as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export type TransactionIsolationLevel =
(typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel];
export const UserScalarFieldEnum = { export const UserScalarFieldEnum = {
id: 'id', id: 'id',
@@ -90,21 +97,23 @@ export const UserScalarFieldEnum = {
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
roleId: 'roleId' roleId: 'roleId'
} as const; } as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum];
export const ProfileScalarFieldEnum = { export const ProfileScalarFieldEnum = {
id: 'id', id: 'id',
userId: 'userId', userId: 'userId',
email: 'email',
firstName: 'firstName', firstName: 'firstName',
lastName: 'lastName', lastName: 'lastName',
phoneNumber: 'phoneNumber', phoneNumber: 'phoneNumber',
ghlContactId: 'ghlContactId' ghlContactId: 'ghlContactId'
} as const; } as const
export type ProfileScalarFieldEnum = (typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum]
export type ProfileScalarFieldEnum =
(typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum];
export const AddressScalarFieldEnum = { export const AddressScalarFieldEnum = {
id: 'id', id: 'id',
@@ -118,10 +127,10 @@ export const AddressScalarFieldEnum = {
isDefaultMailing: 'isDefaultMailing', isDefaultMailing: 'isDefaultMailing',
isDefaultBilling: 'isDefaultBilling', isDefaultBilling: 'isDefaultBilling',
isDefaultShipping: 'isDefaultShipping' isDefaultShipping: 'isDefaultShipping'
} as const; } as const
export type AddressScalarFieldEnum = (typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum]
export type AddressScalarFieldEnum =
(typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum];
export const SessionScalarFieldEnum = { export const SessionScalarFieldEnum = {
id: 'id', id: 'id',
@@ -132,10 +141,10 @@ export const SessionScalarFieldEnum = {
ipAddress: 'ipAddress', ipAddress: 'ipAddress',
userAgent: 'userAgent', userAgent: 'userAgent',
userId: 'userId' userId: 'userId'
} as const; } as const
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
export type SessionScalarFieldEnum =
(typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum];
export const AccountScalarFieldEnum = { export const AccountScalarFieldEnum = {
id: 'id', id: 'id',
@@ -151,18 +160,18 @@ export const AccountScalarFieldEnum = {
password: 'password', password: 'password',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const; } as const
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
export type AccountScalarFieldEnum =
(typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum];
export const UserRoleScalarFieldEnum = { export const UserRoleScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name' name: 'name'
} as const; } as const
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
export type UserRoleScalarFieldEnum =
(typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum];
export const VerificationScalarFieldEnum = { export const VerificationScalarFieldEnum = {
id: 'id', id: 'id',
@@ -171,10 +180,10 @@ export const VerificationScalarFieldEnum = {
expiresAt: 'expiresAt', expiresAt: 'expiresAt',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const; } as const
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
export type VerificationScalarFieldEnum =
(typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum];
export const ProclamationScalarFieldEnum = { export const ProclamationScalarFieldEnum = {
id: 'id', id: 'id',
@@ -182,59 +191,63 @@ export const ProclamationScalarFieldEnum = {
content: 'content', content: 'content',
solfeggioFrequencyId: 'solfeggioFrequencyId', solfeggioFrequencyId: 'solfeggioFrequencyId',
categoryId: 'categoryId', categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId' groupId: 'groupId'
} as const; } as const
export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
export const ProclamationGroupScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder'
} as const
export type ProclamationGroupScalarFieldEnum = (typeof ProclamationGroupScalarFieldEnum)[keyof typeof ProclamationGroupScalarFieldEnum]
export type ProclamationScalarFieldEnum =
(typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum];
export const UserProclamationsScalarFieldEnum = { export const UserProclamationsScalarFieldEnum = {
userId: 'userId', userId: 'userId',
proclamationId: 'proclamationId', proclamationId: 'proclamationId',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const; } as const
export type UserProclamationsScalarFieldEnum = (typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum]
export type UserProclamationsScalarFieldEnum =
(typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum];
export const SolfeggioFrequencyScalarFieldEnum = { export const SolfeggioFrequencyScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
value: 'value' value: 'value'
} as const; } as const
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
export type SolfeggioFrequencyScalarFieldEnum =
(typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum];
export const ProclamationCategoryScalarFieldEnum = { export const ProclamationCategoryScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
sortOrder: 'sortOrder', sortOrder: 'sortOrder'
parentId: 'parentId' } as const
} as const;
export type ProclamationCategoryScalarFieldEnum = export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
(typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum];
export const ProclamationCategorySubscriptionLevelScalarFieldEnum = {
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const;
export type ProclamationCategorySubscriptionLevelScalarFieldEnum =
(typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum];
export const LicenseKeyScalarFieldEnum = { export const LicenseKeyScalarFieldEnum = {
id: 'id', id: 'id',
key: 'key', key: 'key',
active: 'active', active: 'active',
level: 'level', createdAt: 'createdAt',
userId: 'userId' lastValidatedAt: 'lastValidatedAt',
} as const; userId: 'userId',
email: 'email',
systemName: 'systemName',
systemNameLower: 'systemNameLower'
} as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export type LicenseKeyScalarFieldEnum =
(typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum];
export const ApiKeyScalarFieldEnum = { export const ApiKeyScalarFieldEnum = {
id: 'id', id: 'id',
@@ -243,37 +256,119 @@ export const ApiKeyScalarFieldEnum = {
createdAt: 'createdAt', createdAt: 'createdAt',
expiresAt: 'expiresAt', expiresAt: 'expiresAt',
revokedAt: 'revokedAt' revokedAt: 'revokedAt'
} as const; } as const
export type ApiKeyScalarFieldEnum = export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
(typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum];
export const SubscriptionLevelScalarFieldEnum = {
export const ColorPaletteScalarFieldEnum = {
solfeggioFrequencyId: 'solfeggioFrequencyId',
colors: 'colors'
} as const
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
export const AuditLogScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', createdAt: 'createdAt',
description: 'description' userId: 'userId',
} as const; email: 'email',
roleId: 'roleId',
roleName: 'roleName',
page: 'page',
action: 'action',
entity: 'entity',
entityId: 'entityId',
before: 'before',
after: 'after',
ip: 'ip'
} as const
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
licenseKeyId: 'licenseKeyId',
userId: 'userId',
type: 'type',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
day: 'day',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export type SubscriptionLevelScalarFieldEnum =
(typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum];
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
} as const; } as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const JsonNullValueInput = {
JsonNull: JsonNull
} as const
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export const NullableJsonNullValueInput = {
DbNull: DbNull,
JsonNull: JsonNull
} as const
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder];
export const QueryMode = { export const QueryMode = {
default: 'default', default: 'default',
insensitive: 'insensitive' insensitive: 'insensitive'
} as const; } as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
export const NullsOrder = { export const NullsOrder = {
first: 'first', first: 'first',
last: 'last' last: 'last'
} as const; } as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const JsonNullValueFilter = {
DbNull: DbNull,
JsonNull: JsonNull,
AnyNull: AnyNull
} as const
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder];
+21 -16
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
@@ -7,19 +8,23 @@
* *
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
export type * from './models/User.ts'; export type * from './models/User.ts'
export type * from './models/Profile.ts'; export type * from './models/Profile.ts'
export type * from './models/Address.ts'; export type * from './models/Address.ts'
export type * from './models/Session.ts'; export type * from './models/Session.ts'
export type * from './models/Account.ts'; export type * from './models/Account.ts'
export type * from './models/UserRole.ts'; export type * from './models/UserRole.ts'
export type * from './models/Verification.ts'; export type * from './models/Verification.ts'
export type * from './models/Proclamation.ts'; export type * from './models/Proclamation.ts'
export type * from './models/UserProclamations.ts'; export type * from './models/ProclamationGroup.ts'
export type * from './models/SolfeggioFrequency.ts'; export type * from './models/UserProclamations.ts'
export type * from './models/ProclamationCategory.ts'; export type * from './models/SolfeggioFrequency.ts'
export type * from './models/ProclamationCategorySubscriptionLevel.ts'; export type * from './models/ProclamationCategory.ts'
export type * from './models/LicenseKey.ts'; export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'; export type * from './models/ApiKey.ts'
export type * from './models/SubscriptionLevel.ts'; export type * from './models/ColorPalette.ts'
export type * from './commonInputTypes.ts'; export type * from './models/AuditLog.ts'
export type * from './models/Schedule.ts'
export type * from './models/Block.ts'
export type * from './models/BlockProclamations.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More