Compare commits
88
Commits
c52474d69b
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa6669007 | ||
|
|
87934dfd42 | ||
|
|
220ff92e78 | ||
|
|
cc4aa8717e | ||
|
|
e9f94b72ee | ||
|
|
04e1f79f37 | ||
|
|
bbf4ac12ee | ||
|
|
bf465eca60 | ||
|
|
6a3ecaf734 | ||
|
|
e6021a6345 | ||
|
|
be303dcd9c | ||
|
|
fcb5db458b | ||
|
|
242be5d241 | ||
|
|
68a979f1a1 | ||
|
|
f6c937b892 | ||
|
|
9a2888af36 | ||
|
|
3bb8e0189b | ||
|
|
138bf03ea9 | ||
|
|
25b5eeb144 | ||
|
|
3c49f425d8 | ||
|
|
8412dfb69c | ||
|
|
1d76e8dff9 | ||
|
|
179df1e252 | ||
|
|
35558b0652 | ||
|
|
6c89a89050 | ||
|
|
dca9c54d83 | ||
|
|
c19c13b192 | ||
|
|
ec826093eb | ||
|
|
96a1342c10 | ||
|
|
468a65a688 | ||
|
|
e934a98f00 | ||
|
|
15bb51582b | ||
|
|
46d7cf66b8 | ||
|
|
177b7bb13a | ||
|
|
750a9286c9 | ||
|
|
c177e7953d | ||
|
|
5f4116b8e8 | ||
|
|
545fb22910 | ||
|
|
4922e941ae | ||
|
|
e6b492a335 | ||
|
|
051520b941 | ||
|
|
64715c91d3 | ||
|
|
960673bbc6 | ||
|
|
a909db39b1 | ||
|
|
020289966d | ||
|
|
fa614b8c37 | ||
|
|
cf8fc7b719 | ||
|
|
848e646880 | ||
|
|
98d9b095d9 | ||
|
|
2bbee98594 | ||
|
|
4d929df604 | ||
|
|
10aad07e6d | ||
|
|
d16965dab6 | ||
|
|
7596937f30 | ||
|
|
0cb4b2148a | ||
|
|
9de0e2e25a | ||
|
|
9cd5397395 | ||
|
|
f6b8cad15e | ||
|
|
29d53af8dd | ||
|
|
809ced6d8a | ||
|
|
84c905c9ff | ||
|
|
ae8d661364 | ||
|
|
5ec3018f8b | ||
|
|
8a3c1587b4 | ||
|
|
e60d61e90a | ||
|
|
f1d80279e9 | ||
|
|
4979d955c2 | ||
|
|
0175cab0b4 | ||
|
|
1aecc51bbd | ||
|
|
f50eb34769 | ||
|
|
4a1777de87 | ||
|
|
d89fc6837c | ||
|
|
5e8851e070 | ||
|
|
91d162c746 | ||
|
|
f4e736c92d | ||
|
|
5096118ef5 | ||
|
|
86b6720676 | ||
|
|
94c9877eab | ||
|
|
0753cfaa56 | ||
|
|
7211beef69 | ||
|
|
dea5cd291a | ||
|
|
64f7070ad7 | ||
|
|
b2091deae0 | ||
|
|
74896234f9 | ||
|
|
cca26c0578 | ||
|
|
23e232d5ea | ||
|
|
b746d89448 | ||
|
|
dd8c194de2 |
+5
-1
@@ -5,4 +5,8 @@ GOOGLE_CLIENT_ID=6
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
RESEND_API_KEY=
|
||||
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=
|
||||
@@ -6,11 +6,15 @@ on:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: orion-lxc
|
||||
|
||||
steps:
|
||||
- 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
|
||||
run: |
|
||||
@@ -20,7 +24,7 @@ jobs:
|
||||
.
|
||||
|
||||
- name: Login to DigitalOcean Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: registry.digitalocean.com
|
||||
username: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
@@ -30,3 +34,29 @@ jobs:
|
||||
run: |
|
||||
docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }}
|
||||
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
|
||||
@@ -7,3 +7,6 @@ bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
|
||||
# Generated code
|
||||
/src/lib/generated/
|
||||
|
||||
Vendored
-27
@@ -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/*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{}
|
||||
Vendored
-26
@@ -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:.*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,11 +9,11 @@ AGENTS.md
|
||||
at your project root:
|
||||
|
||||
````text
|
||||
# Rhemawave Portal AI Guide
|
||||
# Radiant Wave Portal AI Guide
|
||||
|
||||
## Mission
|
||||
|
||||
You are working on portal.Rhemawavetech.com.
|
||||
You are working on portal.radiantwavetech.com.
|
||||
|
||||
Act as a senior engineer making complete, production-ready changes.
|
||||
|
||||
@@ -97,9 +97,9 @@ Use narrower commands when appropriate.
|
||||
|
||||
If validation fails:
|
||||
|
||||
* Diagnose the failure.
|
||||
* Fix the issue.
|
||||
* Run validation again.
|
||||
- Diagnose the failure.
|
||||
- Fix the issue.
|
||||
- Run validation again.
|
||||
|
||||
Never finish with known failures.
|
||||
|
||||
@@ -109,19 +109,19 @@ Never finish with known failures.
|
||||
|
||||
Application:
|
||||
|
||||
portal.Rhemawavetech.com
|
||||
portal.radiantwavetech.com
|
||||
|
||||
Stack:
|
||||
|
||||
* SvelteKit 2
|
||||
* Svelte 5
|
||||
* TypeScript
|
||||
* Tailwind CSS v4
|
||||
* Prisma 7
|
||||
* PostgreSQL
|
||||
* Better Auth
|
||||
* Vitest
|
||||
* Playwright
|
||||
- SvelteKit 2
|
||||
- Svelte 5
|
||||
- TypeScript
|
||||
- Tailwind CSS v4
|
||||
- Prisma 7
|
||||
- PostgreSQL
|
||||
- Better Auth
|
||||
- Vitest
|
||||
- Playwright
|
||||
|
||||
Package manager:
|
||||
|
||||
@@ -266,9 +266,9 @@ AdminPage.svelte
|
||||
|
||||
Registry metadata exists for:
|
||||
|
||||
* columns
|
||||
* searches
|
||||
* relations
|
||||
- columns
|
||||
- searches
|
||||
- relations
|
||||
|
||||
Forms are manually implemented per route.
|
||||
|
||||
@@ -309,8 +309,8 @@ Only load that file when modifying Svelte components or Svelte logic.
|
||||
|
||||
Testing uses:
|
||||
|
||||
* Vitest
|
||||
* Playwright browser testing
|
||||
- Vitest
|
||||
- Playwright browser testing
|
||||
|
||||
For testing patterns, read:
|
||||
|
||||
@@ -338,10 +338,10 @@ Only load that file when working on API functionality.
|
||||
|
||||
Prettier rules:
|
||||
|
||||
* tabs
|
||||
* single quotes
|
||||
* no trailing commas
|
||||
* 80 character print width
|
||||
- tabs
|
||||
- single quotes
|
||||
- no trailing commas
|
||||
- 80 character print width
|
||||
|
||||
Follow existing formatting.
|
||||
|
||||
@@ -391,17 +391,14 @@ Confirm:
|
||||
2. Relevant tests/checks were run.
|
||||
3. Failures were fixed.
|
||||
4. The final response explains:
|
||||
|
||||
* what changed
|
||||
* files modified
|
||||
* verification performed
|
||||
- what changed
|
||||
- files modified
|
||||
- verification performed
|
||||
|
||||
Do not stop at:
|
||||
|
||||
* "I found the issue"
|
||||
* "Here is a plan"
|
||||
* "The next step would be"
|
||||
- "I found the issue"
|
||||
- "Here is a plan"
|
||||
- "The next step would be"
|
||||
|
||||
Finish the work.
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# portal.Rhemawavetech.com
|
||||
# portal.radiantwavetech.com
|
||||
|
||||
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
|
||||
|
||||
@@ -332,7 +332,7 @@ The `$effect` resets `dirty = false` on mount (when the record prop changes).
|
||||
| Template ID | Variables | Subject (API-level) |
|
||||
| ------------------------------ | --------------------------------------------------------------- | ------------------------------ |
|
||||
| `automated-user-registration` | `firstName`, `tempPassword`, `verifyUrl`, `loginUrl` | "Your account is ready" |
|
||||
| `license-key-delivery` | `firstName`, `license_key` | "Your Rhemawave License Key" |
|
||||
| `license-key-delivery` | `firstName`, `license_key` | "Your Radiant Wave License Key" |
|
||||
| `email-verification-email` | `target_email_address`, `verification_link` | "Verify your email address" |
|
||||
|
||||
Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal `{{{varName}}}` text.
|
||||
|
||||
@@ -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/node_modules ./node_modules
|
||||
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/prisma ./prisma
|
||||
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Rhema Wave Technologies - User Portal
|
||||
# Radiant Wave Technologies - User Portal
|
||||
|
||||
A combination application that combines the main Radiant Wave API with a user-facing account management application,
|
||||
and an admin-facing platform management application. An all-in-one management platform for Radiant Wave and Radiant Wave Technologies.
|
||||
@@ -36,18 +36,17 @@ podman push registry.digitalocean.com/radiantwave-portal-container-registry/port
|
||||
|
||||
## 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 |
|
||||
| ------------------------ | --------------------------------- |
|
||||
| users | Session (Better Auth) |
|
||||
| profiles | Account (Better Auth) |
|
||||
| addresses | Verification (Better Auth) |
|
||||
| user-roles | UserProclamations (composite key) |
|
||||
| proclamations | |
|
||||
| solfeggio-frequencies | |
|
||||
| proclamation-categories | |
|
||||
| license-keys (list-only) | |
|
||||
| subscription-levels | |
|
||||
|
||||
See `src/lib/svelte-admin/README.md` for architecture and configuration docs.
|
||||
| Route | Notes |
|
||||
| -------------------------------- | ------------------------ |
|
||||
| `/admin/users` | Name, Email, Role |
|
||||
| `/admin/profiles` | Reference implementation |
|
||||
| `/admin/proclamations` | Super-only content edits |
|
||||
| `/admin/solfeggio-frequencies` | |
|
||||
| `/admin/proclamation-categories` | Draggable reorder |
|
||||
| `/admin/proclamation-groups` | Draggable reorder |
|
||||
| `/admin/license-keys` | List-only + rotate |
|
||||
|
||||
+22
-4
@@ -89,14 +89,34 @@ POST:
|
||||
Payload:
|
||||
|
||||
{
|
||||
"license_key": "...",
|
||||
"email_address": "..."
|
||||
"license_key": "...",
|
||||
"email_address": "..."
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Endpoint:
|
||||
@@ -153,7 +173,6 @@ Variables:
|
||||
- verifyUrl
|
||||
- loginUrl
|
||||
|
||||
|
||||
license-key-delivery
|
||||
|
||||
Variables:
|
||||
@@ -161,7 +180,6 @@ Variables:
|
||||
- firstName
|
||||
- license_key
|
||||
|
||||
|
||||
email-verification-email
|
||||
|
||||
Variables:
|
||||
|
||||
+13
-12
@@ -36,13 +36,13 @@ Always destructure $props() with defaults.
|
||||
Preferred pattern:
|
||||
|
||||
let {
|
||||
record = {} as Record<string, unknown>,
|
||||
fieldErrors = {} as Record<string, string>,
|
||||
isSuper = false
|
||||
record = {} as Record<string, unknown>,
|
||||
fieldErrors = {} as Record<string, string>,
|
||||
isSuper = false
|
||||
}: {
|
||||
record?: Record<string, unknown>;
|
||||
fieldErrors?: Record<string, string>;
|
||||
isSuper?: boolean;
|
||||
record?: Record<string, unknown>;
|
||||
fieldErrors?: Record<string, string>;
|
||||
isSuper?: boolean;
|
||||
} = $props();
|
||||
|
||||
Do not assume props exist.
|
||||
@@ -66,7 +66,7 @@ Correct:
|
||||
let name = $state('');
|
||||
|
||||
$effect(() => {
|
||||
name = String(record.name ?? '');
|
||||
name = String(record.name ?? '');
|
||||
});
|
||||
|
||||
Use $effect whenever component state needs to follow changing props.
|
||||
@@ -88,11 +88,11 @@ Example:
|
||||
let dirty = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
dirty = false;
|
||||
dirty = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
name = String(record.name ?? '');
|
||||
name = String(record.name ?? '');
|
||||
});
|
||||
|
||||
This ensures a new record starts clean.
|
||||
@@ -116,9 +116,10 @@ This can fail to update correctly.
|
||||
Use:
|
||||
|
||||
<select onchange={(e) => frequency = e.currentTarget.value}>
|
||||
<option value="1" selected={frequency === '1'}>
|
||||
One
|
||||
</option>
|
||||
|
||||
<option value="1" selected={frequency === '1'}>
|
||||
One
|
||||
</option>
|
||||
</select>
|
||||
|
||||
Use selected on options.
|
||||
|
||||
+29
-29
@@ -30,13 +30,13 @@ Tests are colocated with source files.
|
||||
|
||||
Client tests:
|
||||
|
||||
*.svelte.test.ts
|
||||
*.svelte.spec.ts
|
||||
_.svelte.test.ts
|
||||
_.svelte.spec.ts
|
||||
|
||||
Server tests:
|
||||
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
_.test.ts
|
||||
_.spec.ts
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +79,7 @@ Node.
|
||||
Always reset mocks:
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
---
|
||||
@@ -109,15 +109,15 @@ Mock only the methods used.
|
||||
Example:
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamation: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
}
|
||||
}
|
||||
prisma: {
|
||||
proclamation: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Do not create massive unused mocks.
|
||||
@@ -143,7 +143,7 @@ createLocals(mockUser() as any)
|
||||
Specific roles:
|
||||
|
||||
createLocals(
|
||||
mockUser({ roleId: 4 }) as any
|
||||
mockUser({ roleId: 4 }) as any
|
||||
)
|
||||
|
||||
---
|
||||
@@ -172,15 +172,15 @@ Always use exact regex names.
|
||||
Preferred:
|
||||
|
||||
page.getByRole(
|
||||
'textbox',
|
||||
{ name: /^Name$/ }
|
||||
'textbox',
|
||||
{ name: /^Name$/ }
|
||||
)
|
||||
|
||||
Avoid:
|
||||
|
||||
page.getByRole(
|
||||
'textbox',
|
||||
{ name: 'Name' }
|
||||
'textbox',
|
||||
{ name: 'Name' }
|
||||
)
|
||||
|
||||
because substring matching can create strict-mode failures.
|
||||
@@ -192,15 +192,15 @@ because substring matching can create strict-mode failures.
|
||||
Because selects use selected options instead of bind:value:
|
||||
|
||||
const element =
|
||||
await page
|
||||
.getByRole(
|
||||
'combobox',
|
||||
{ name: /^Frequency$/ }
|
||||
)
|
||||
.element();
|
||||
await page
|
||||
.getByRole(
|
||||
'combobox',
|
||||
{ name: /^Frequency$/ }
|
||||
)
|
||||
.element();
|
||||
|
||||
expect(
|
||||
(element as HTMLSelectElement).value
|
||||
(element as HTMLSelectElement).value
|
||||
).toBe('2');
|
||||
|
||||
---
|
||||
@@ -213,9 +213,9 @@ Use:
|
||||
|
||||
vi.mock('$lib/components/example.svelte', () => ({
|
||||
default: ($anchor: Element) => {
|
||||
const div = document.createElement('div');
|
||||
$anchor.replaceWith(div);
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
$anchor.replaceWith(div);
|
||||
}
|
||||
}));
|
||||
|
||||
Imports using mocked modules must come after vi.mock().
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "sched-none-0001",
|
||||
"type": null,
|
||||
"daily": [],
|
||||
"weekly": {},
|
||||
"blocks": []
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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 />
|
||||
© Radiant Wave Technologies
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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">
|
||||
© Radiant Wave Technologies
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 />
|
||||
© Radiant Wave Technologies
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 />
|
||||
© Radiant Wave Technologies
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -37,5 +37,11 @@ export default defineConfig(
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.spec.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "portal.Rhemawavetech.com",
|
||||
"name": "portal.radiantwavetech.com",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "portal.Rhemawavetech.com",
|
||||
"name": "portal.radiantwavetech.com",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "portal.Rhemawavetech.com",
|
||||
"name": "portal.radiantwavetech.com",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
@@ -7,7 +7,7 @@
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "NODE_EXTRA_CA_CERTS=./digital-ocean.crt vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
|
||||
@@ -5,6 +5,15 @@ import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
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: {
|
||||
path: 'prisma/migrations',
|
||||
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")
|
||||
);
|
||||
+115
@@ -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 $$;
|
||||
+173
-45
@@ -21,13 +21,14 @@ model User {
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
roleId Int?
|
||||
roleId Int? @default(4) // New users default to "User"
|
||||
role UserRole? @relation(fields: [roleId], references: [id])
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
profile Profile?
|
||||
profiles Profile[]
|
||||
licenseKeys LicenseKey[]
|
||||
userProclamations UserProclamations[]
|
||||
schedules Schedule[]
|
||||
|
||||
@@unique([email])
|
||||
@@map("user")
|
||||
@@ -37,28 +38,32 @@ model User {
|
||||
// a bunch of time trying to figure out how to customize the default Better-Auth model.
|
||||
model Profile {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
email String?
|
||||
firstName String
|
||||
lastName String
|
||||
phoneNumber String
|
||||
ghlContactId String? @map("ghl_contact_id")
|
||||
addresses Address[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model Address {
|
||||
id String @id @default(uuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
street String
|
||||
city String
|
||||
state String
|
||||
postalCode String
|
||||
country String
|
||||
type String @default("Mailing")
|
||||
isDefaultMailing Boolean @default(false)
|
||||
isDefaultBilling Boolean @default(false)
|
||||
isDefaultShipping Boolean @default(false)
|
||||
id String @id @default(uuid())
|
||||
profileId String
|
||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
street String
|
||||
city String
|
||||
state String
|
||||
postalCode String
|
||||
country String
|
||||
type String @default("Mailing")
|
||||
isDefaultMailing Boolean @default(false)
|
||||
isDefaultBilling Boolean @default(false)
|
||||
isDefaultShipping Boolean @default(false)
|
||||
}
|
||||
|
||||
model Session {
|
||||
@@ -123,9 +128,20 @@ model Proclamation {
|
||||
solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])
|
||||
categoryId Int
|
||||
category ProclamationCategory @relation(fields: [categoryId], references: [id])
|
||||
groupId Int
|
||||
group ProclamationGroup @relation(fields: [groupId], references: [id])
|
||||
userProclamations UserProclamations[]
|
||||
subscriptionLevelId Int // Keeping old FK for migration stability
|
||||
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id])
|
||||
blockProclamations BlockProclamations[]
|
||||
// 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 {
|
||||
@@ -142,40 +158,67 @@ model UserProclamations {
|
||||
model SolfeggioFrequency {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
value Int @unique
|
||||
value Int @unique
|
||||
proclamations Proclamation[]
|
||||
}
|
||||
|
||||
model ProclamationCategory {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
sortOrder Int @default(0)
|
||||
parentId Int?
|
||||
parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||
children ProclamationCategory[] @relation("CategoryHierarchy")
|
||||
proclamations Proclamation[]
|
||||
subscriptionLevels ProclamationCategorySubscriptionLevel[]
|
||||
|
||||
@@unique([parentId, name])
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
proclamations Proclamation[]
|
||||
}
|
||||
|
||||
// Join table for ProclamationCategory -> SubscriptionLevel defaults
|
||||
model ProclamationCategorySubscriptionLevel {
|
||||
// Archive of the former child sub-categories removed when the
|
||||
// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.
|
||||
// `@@ignore` keeps it out of the generated client while Prisma remains aware
|
||||
// of the table so `migrate dev` does not report it as an untracked table.
|
||||
model ProclamationCategoryLegacy {
|
||||
id Int @id
|
||||
name String
|
||||
parentId Int?
|
||||
sortOrder Int @default(0)
|
||||
|
||||
@@map("ProclamationCategoryLegacy")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
// Archive of the category->subscription-level mappings for the child
|
||||
// categories removed when the category hierarchy was flattened. Kept with
|
||||
// `@@ignore` so Prisma remains aware of the table but it is excluded from the
|
||||
// generated client and Prisma's own migrations.
|
||||
model ProclamationCategorySubscriptionLevelLegacy {
|
||||
categoryId Int
|
||||
category ProclamationCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
subscriptionLevelId Int
|
||||
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([categoryId, subscriptionLevelId])
|
||||
@@map("ProclamationCategorySubscriptionLevelLegacy")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model LicenseKey {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
active Boolean
|
||||
level String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
active Boolean
|
||||
createdAt DateTime @default(now())
|
||||
lastValidatedAt DateTime?
|
||||
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 {
|
||||
@@ -187,10 +230,95 @@ model ApiKey {
|
||||
revokedAt DateTime?
|
||||
}
|
||||
|
||||
model SubscriptionLevel {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
proclamations Proclamation[]
|
||||
categoryLevels ProclamationCategorySubscriptionLevel[]
|
||||
// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).
|
||||
// `solfeggioFrequencyId` is the canonical id the kiosk uses (see
|
||||
// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.
|
||||
model ColorPalette {
|
||||
solfeggioFrequencyId Int @id
|
||||
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
@@ -21,46 +21,77 @@ const solfeggioFrequencies = [
|
||||
{ id: 4, name: 'Heart', value: 639 },
|
||||
{ id: 5, name: 'Solar Plexus', value: 528 },
|
||||
{ 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 = [
|
||||
{ id: 1, name: 'Basic', description: 'General only' },
|
||||
{ id: 2, name: 'Advanced', description: 'General + Specific' },
|
||||
{ id: 3, name: 'Elite', description: 'General + Specific + Precise' }
|
||||
// Canonical id -> 7-color palette, matching the kiosk's
|
||||
// DatabaseManager::seedColorPalettes() (radiantwave-rebuild).
|
||||
const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [
|
||||
{
|
||||
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 = [
|
||||
{ id: 4, name: 'Spiritual', parentId: null },
|
||||
{ id: 5, name: 'General', parentId: 4 },
|
||||
{ id: 9, name: 'Specific', parentId: 4 },
|
||||
{ id: 40, name: 'Precise', parentId: 4 },
|
||||
{ id: 2, name: 'Physical', parentId: null },
|
||||
{ id: 3, name: 'General', parentId: 2 },
|
||||
{ id: 10, name: 'Specific', parentId: 2 },
|
||||
{ id: 32, name: 'Precise', parentId: 2 },
|
||||
{ id: 16, name: 'Mental', parentId: null },
|
||||
{ id: 30, name: 'General', parentId: 16 },
|
||||
{ id: 31, name: 'Specific', parentId: 16 },
|
||||
{ id: 38, name: 'Precise', parentId: 16 },
|
||||
{ id: 17, name: 'Custom (Client Specific)', parentId: null },
|
||||
{ id: 19, name: 'Financial', parentId: null },
|
||||
{ id: 28, name: 'General', parentId: 19 },
|
||||
{ id: 29, name: 'Specific', parentId: 19 },
|
||||
{ id: 37, name: 'Precise', parentId: 19 },
|
||||
{ id: 20, name: 'Emotional', parentId: null },
|
||||
{ id: 24, name: 'General', parentId: 20 },
|
||||
{ id: 26, name: 'Specific', parentId: 20 },
|
||||
{ id: 45, name: 'Precise', parentId: 20 },
|
||||
{ id: 21, name: 'Social', parentId: null },
|
||||
{ id: 22, name: 'General', parentId: 21 },
|
||||
{ id: 23, name: 'Specific', parentId: 21 },
|
||||
{ id: 39, name: 'Precise', parentId: 21 },
|
||||
{ id: 41, name: 'Biblical', parentId: null },
|
||||
{ id: 42, name: 'Personal Growth & Development', parentId: null },
|
||||
{ id: 43, name: 'Prayers', parentId: 41 },
|
||||
{ id: 44, name: 'Scripture', parentId: 41 },
|
||||
{ id: 46, name: 'Sports', parentId: null }
|
||||
{ id: 4, name: 'Spiritual' },
|
||||
{ id: 2, name: 'Physical' },
|
||||
{ id: 16, name: 'Mental' },
|
||||
{ id: 17, name: 'Custom (Client Specific)' },
|
||||
{ id: 19, name: 'Financial' },
|
||||
{ id: 20, name: 'Emotional' },
|
||||
{ id: 21, name: 'Social' },
|
||||
{ id: 41, name: 'Biblical' },
|
||||
{ id: 42, name: 'Personal Growth & Development' },
|
||||
{ id: 46, name: 'Sports' }
|
||||
];
|
||||
|
||||
async function main() {
|
||||
@@ -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...');
|
||||
for (const cat of proclamationCategories) {
|
||||
await prisma.proclamationCategory.upsert({
|
||||
@@ -91,12 +131,12 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeding SubscriptionLevel...');
|
||||
for (const level of subscriptionLevels) {
|
||||
await prisma.subscriptionLevel.upsert({
|
||||
where: { id: level.id },
|
||||
create: level,
|
||||
update: level
|
||||
console.log('Seeding ColorPalette...');
|
||||
for (const palette of colorPalettes) {
|
||||
await prisma.colorPalette.upsert({
|
||||
where: { solfeggioFrequencyId: palette.solfeggioFrequencyId },
|
||||
create: palette,
|
||||
update: palette
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
Vendored
+5
-3
@@ -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 {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
session: typeof auth.$Infer.Session.session | null;
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
apiKey?: ApiKeyRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -5,21 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function () {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (
|
||||
theme === 'dark' ||
|
||||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
try {
|
||||
const theme = localStorage.getItem('theme');
|
||||
const dark =
|
||||
theme === 'dark' ||
|
||||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body class="m-0" data-sveltekit-preload-data="hover">
|
||||
<div class="flex h-dvh w-dvw flex-col">%sveltekit.body%</div>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+114
-8
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
const { getSession } = vi.hoisted(() => ({
|
||||
getSession: vi.fn()
|
||||
@@ -8,10 +9,19 @@ const { svelteKitHandler } = vi.hoisted(() => ({
|
||||
svelteKitHandler: vi.fn()
|
||||
}));
|
||||
|
||||
const { validateApiKeyMock, envMock } = vi.hoisted(() => ({
|
||||
validateApiKeyMock: vi.fn(),
|
||||
envMock: { API_KEYS_ACTIVE: undefined as string | undefined }
|
||||
}));
|
||||
|
||||
vi.mock('$app/environment', () => ({
|
||||
building: false
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: envMock
|
||||
}));
|
||||
|
||||
vi.mock('better-auth/svelte-kit', () => ({
|
||||
svelteKitHandler
|
||||
}));
|
||||
@@ -25,24 +35,33 @@ vi.mock('$lib/auth', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/api-key', () => ({
|
||||
validateApiKey: vi.fn()
|
||||
validateApiKey: validateApiKeyMock
|
||||
}));
|
||||
|
||||
import { handle } from './hooks.server';
|
||||
|
||||
function createMockEvent(path = '/dashboard') {
|
||||
type MockRequestEvent = RequestEvent & { resolve: ReturnType<typeof vi.fn> };
|
||||
|
||||
function createMockEvent(path = '/dashboard'): MockRequestEvent {
|
||||
return {
|
||||
request: new Request(`http://localhost${path}`),
|
||||
url: new URL(`http://localhost${path}`),
|
||||
locals: {} as Record<string, unknown>,
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
getSession.mockReset();
|
||||
svelteKitHandler.mockReset();
|
||||
validateApiKeyMock.mockReset();
|
||||
envMock.API_KEYS_ACTIVE = undefined;
|
||||
});
|
||||
|
||||
it('sets session and user on locals when authenticated', async () => {
|
||||
@@ -53,7 +72,7 @@ describe('hooks.server handle', () => {
|
||||
getSession.mockResolvedValue(sessionData);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
await runHandle(event);
|
||||
|
||||
expect(event.locals.session).toEqual(sessionData.session);
|
||||
expect(event.locals.user).toEqual(sessionData.user);
|
||||
@@ -70,7 +89,7 @@ describe('hooks.server handle', () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
await runHandle(event);
|
||||
|
||||
expect(event.locals.session).toBeUndefined();
|
||||
expect(event.locals.user).toBeUndefined();
|
||||
@@ -80,7 +99,7 @@ describe('hooks.server handle', () => {
|
||||
getSession.mockResolvedValue(undefined);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
await runHandle(event);
|
||||
|
||||
expect(event.locals.session).toBeUndefined();
|
||||
expect(event.locals.user).toBeUndefined();
|
||||
@@ -90,7 +109,7 @@ describe('hooks.server handle', () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
await runHandle(event);
|
||||
|
||||
expect(svelteKitHandler).toHaveBeenCalledWith(expect.objectContaining({ building: false }));
|
||||
});
|
||||
@@ -99,7 +118,7 @@ describe('hooks.server handle', () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
|
||||
const event = createMockEvent();
|
||||
await handle({ event, resolve: event.resolve });
|
||||
await runHandle(event);
|
||||
|
||||
expect(svelteKitHandler).toHaveBeenCalledWith(
|
||||
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();
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
+82
-29
@@ -1,41 +1,94 @@
|
||||
import { auth } from '$lib/auth';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { building } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
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 }) {
|
||||
if (
|
||||
event.url.pathname.startsWith('/api/v1/') &&
|
||||
!event.url.pathname.startsWith('/api/v1/automations/')
|
||||
) {
|
||||
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' }
|
||||
});
|
||||
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 (
|
||||
event.url.pathname.startsWith('/api/v1/') &&
|
||||
!event.url.pathname.startsWith('/api/v1/automations/')
|
||||
) {
|
||||
if (env.API_KEYS_ACTIVE !== 'false') {
|
||||
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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
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' }
|
||||
});
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
|
||||
return runWithAuditContext(contextFor(event, session.user), () =>
|
||||
svelteKitHandler({ event, resolve, auth, building })
|
||||
);
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
});
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
}
|
||||
|
||||
+36
-1
@@ -5,13 +5,40 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { sendMail } from './server/resend';
|
||||
import { sendVerificationEmail } from './server/email-verification';
|
||||
import { sendRegistrationEmail } from './server/registration-email';
|
||||
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({
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
database: prismaAdapter(prisma, {
|
||||
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: {
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ user, token }) => {
|
||||
@@ -27,8 +54,16 @@ export const auth = betterAuth({
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
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({
|
||||
to: user.email,
|
||||
subject: 'Reset your Radiant Wave password',
|
||||
templateId: 'password-reset-email',
|
||||
data: {
|
||||
target_email_address: user.email,
|
||||
@@ -51,6 +86,6 @@ export const auth = betterAuth({
|
||||
window: 60,
|
||||
max: 100
|
||||
},
|
||||
trustedOrigins: ['http://localhost:5173', 'https://portal.rhemawavetech.com'],
|
||||
trustedOrigins: ['http://localhost:5173', 'https://portal.radiantwavetech.com'],
|
||||
plugins: [sveltekitCookies(getRequestEvent)]
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,8 @@
|
||||
deleteAction?: string;
|
||||
} = $props();
|
||||
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose?.();
|
||||
function handleBackdrop() {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -44,24 +44,27 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
role="presentation"
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-12"
|
||||
onclick={handleBackdrop}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
onkeydown={handleKeydown}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="absolute inset-0" onclick={handleBackdrop} aria-hidden="true"></div>
|
||||
<div
|
||||
class="my-8 w-full max-w-2xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"
|
||||
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
|
||||
class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
|
||||
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-semibold text-gray-900 dark:text-gray-100">{title}</h2>
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">{title}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if canDelete && recordId}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -69,12 +72,14 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="px-2 py-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>×</button
|
||||
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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-y-auto px-6 py-5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +1,5 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
message = '',
|
||||
type = 'success' as 'success' | 'error'
|
||||
}: {
|
||||
message?: string;
|
||||
type?: 'success' | 'error';
|
||||
} = $props();
|
||||
let { message = '' }: { message?: string } = $props();
|
||||
|
||||
let remaining = $state(5);
|
||||
let visible = $state(true);
|
||||
@@ -32,15 +26,17 @@
|
||||
{#if visible}
|
||||
<div
|
||||
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 ===
|
||||
'success'
|
||||
? 'border-green-600 bg-green-50 text-green-800'
|
||||
: 'border-red-600 bg-red-50 text-red-800'}"
|
||||
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"
|
||||
>
|
||||
<span class="flex-1">{message}</span>
|
||||
<span class="text-xs tabular-nums opacity-70">{remaining}s</span>
|
||||
<button type="button" onclick={dismiss} class="ml-1 leading-none font-bold hover:opacity-70"
|
||||
>×</button
|
||||
<span>{message}</span>
|
||||
<span class="text-xs text-slate-400 dark:text-slate-500">{remaining}s</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismiss}
|
||||
aria-label="Dismiss"
|
||||
class="rounded p-1 text-slate-400 transition hover:text-white dark:text-slate-500"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto">
|
||||
{@render children()}
|
||||
<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()}
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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' });
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
+161
-7
@@ -2,18 +2,172 @@ import pg from 'pg';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '$lib/generated/prisma/client';
|
||||
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 _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 {
|
||||
if (!_prisma) {
|
||||
const pool = new pg.Pool({
|
||||
connectionString: env.DATABASE_URL
|
||||
});
|
||||
if (_prisma) return _prisma;
|
||||
|
||||
const adapter = new PrismaPg(pool);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_prisma = extended as unknown as PrismaClient;
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
@@ -12,83 +13,102 @@
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser.ts';
|
||||
export { Prisma };
|
||||
export * as $Enums from './enums.ts';
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums.ts'
|
||||
export * from './enums.ts';
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel;
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model Profile
|
||||
*
|
||||
*/
|
||||
export type Profile = Prisma.ProfileModel;
|
||||
export type Profile = Prisma.ProfileModel
|
||||
/**
|
||||
* Model Address
|
||||
*
|
||||
*/
|
||||
export type Address = Prisma.AddressModel;
|
||||
export type Address = Prisma.AddressModel
|
||||
/**
|
||||
* Model Session
|
||||
*
|
||||
*/
|
||||
export type Session = Prisma.SessionModel;
|
||||
export type Session = Prisma.SessionModel
|
||||
/**
|
||||
* Model Account
|
||||
*
|
||||
*/
|
||||
export type Account = Prisma.AccountModel;
|
||||
export type Account = Prisma.AccountModel
|
||||
/**
|
||||
* Model UserRole
|
||||
*
|
||||
*/
|
||||
export type UserRole = Prisma.UserRoleModel;
|
||||
export type UserRole = Prisma.UserRoleModel
|
||||
/**
|
||||
* Model Verification
|
||||
*
|
||||
*/
|
||||
export type Verification = Prisma.VerificationModel;
|
||||
export type Verification = Prisma.VerificationModel
|
||||
/**
|
||||
* Model Proclamation
|
||||
*
|
||||
*/
|
||||
export type Proclamation = Prisma.ProclamationModel;
|
||||
export type Proclamation = Prisma.ProclamationModel
|
||||
/**
|
||||
* Model ProclamationGroup
|
||||
*
|
||||
*/
|
||||
export type ProclamationGroup = Prisma.ProclamationGroupModel
|
||||
/**
|
||||
* Model UserProclamations
|
||||
*
|
||||
*/
|
||||
export type UserProclamations = Prisma.UserProclamationsModel;
|
||||
export type UserProclamations = Prisma.UserProclamationsModel
|
||||
/**
|
||||
* Model SolfeggioFrequency
|
||||
*
|
||||
*/
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
|
||||
/**
|
||||
* Model ProclamationCategory
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
|
||||
/**
|
||||
* Model ProclamationCategorySubscriptionLevel
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategorySubscriptionLevel =
|
||||
Prisma.ProclamationCategorySubscriptionLevelModel;
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel
|
||||
/**
|
||||
* Model LicenseKey
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel;
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
@@ -9,18 +10,18 @@
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url));
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
import * as runtime from '@prisma/client/runtime/client';
|
||||
import * as $Enums from './enums.ts';
|
||||
import * as $Class from './internal/class.ts';
|
||||
import * as Prisma from './internal/prismaNamespace.ts';
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.ts"
|
||||
import * as $Class from "./internal/class.ts"
|
||||
import * as Prisma from "./internal/prismaNamespace.ts"
|
||||
|
||||
export * as $Enums from './enums.ts';
|
||||
export * from './enums.ts';
|
||||
export * as $Enums from './enums.ts'
|
||||
export * from "./enums.ts"
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
@@ -36,87 +37,102 @@ export * from './enums.ts';
|
||||
*
|
||||
* Read more in our [docs](https://pris.ly/d/client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass();
|
||||
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>;
|
||||
export { Prisma };
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
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>
|
||||
export { Prisma }
|
||||
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel;
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model Profile
|
||||
*
|
||||
*/
|
||||
export type Profile = Prisma.ProfileModel;
|
||||
export type Profile = Prisma.ProfileModel
|
||||
/**
|
||||
* Model Address
|
||||
*
|
||||
*/
|
||||
export type Address = Prisma.AddressModel;
|
||||
export type Address = Prisma.AddressModel
|
||||
/**
|
||||
* Model Session
|
||||
*
|
||||
*/
|
||||
export type Session = Prisma.SessionModel;
|
||||
export type Session = Prisma.SessionModel
|
||||
/**
|
||||
* Model Account
|
||||
*
|
||||
*/
|
||||
export type Account = Prisma.AccountModel;
|
||||
export type Account = Prisma.AccountModel
|
||||
/**
|
||||
* Model UserRole
|
||||
*
|
||||
*/
|
||||
export type UserRole = Prisma.UserRoleModel;
|
||||
export type UserRole = Prisma.UserRoleModel
|
||||
/**
|
||||
* Model Verification
|
||||
*
|
||||
*/
|
||||
export type Verification = Prisma.VerificationModel;
|
||||
export type Verification = Prisma.VerificationModel
|
||||
/**
|
||||
* Model Proclamation
|
||||
*
|
||||
*/
|
||||
export type Proclamation = Prisma.ProclamationModel;
|
||||
export type Proclamation = Prisma.ProclamationModel
|
||||
/**
|
||||
* Model ProclamationGroup
|
||||
*
|
||||
*/
|
||||
export type ProclamationGroup = Prisma.ProclamationGroupModel
|
||||
/**
|
||||
* Model UserProclamations
|
||||
*
|
||||
*/
|
||||
export type UserProclamations = Prisma.UserProclamationsModel;
|
||||
export type UserProclamations = Prisma.UserProclamationsModel
|
||||
/**
|
||||
* Model SolfeggioFrequency
|
||||
*
|
||||
*/
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
|
||||
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
|
||||
/**
|
||||
* Model ProclamationCategory
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
|
||||
/**
|
||||
* Model ProclamationCategorySubscriptionLevel
|
||||
*
|
||||
*/
|
||||
export type ProclamationCategorySubscriptionLevel =
|
||||
Prisma.ProclamationCategorySubscriptionLevelModel;
|
||||
export type ProclamationCategory = Prisma.ProclamationCategoryModel
|
||||
/**
|
||||
* Model LicenseKey
|
||||
*
|
||||
*/
|
||||
export type LicenseKey = Prisma.LicenseKeyModel;
|
||||
export type LicenseKey = Prisma.LicenseKeyModel
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
@@ -8,395 +9,548 @@
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from '@prisma/client/runtime/client';
|
||||
import * as $Enums from './enums.ts';
|
||||
import type * as Prisma from './internal/prismaNamespace.ts';
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.ts"
|
||||
import type * as Prisma from "./internal/prismaNamespace.ts"
|
||||
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type BoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
|
||||
};
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type IntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder;
|
||||
nulls?: Prisma.NullsOrder;
|
||||
};
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
mode?: Prisma.QueryMode;
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: 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> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedBoolFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
|
||||
};
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBoolFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null;
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null;
|
||||
};
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>;
|
||||
};
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number;
|
||||
};
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
// 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. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
@@ -14,266 +15,360 @@
|
||||
* 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 './prismaNamespace.ts';
|
||||
export type * from '../models.ts'
|
||||
export type * from './prismaNamespace.ts'
|
||||
|
||||
export const Decimal = runtime.Decimal
|
||||
|
||||
export const Decimal = runtime.Decimal;
|
||||
|
||||
export const NullTypes = {
|
||||
DbNull: runtime.NullTypes.DbNull as new (secret: never) => typeof runtime.DbNull,
|
||||
JsonNull: runtime.NullTypes.JsonNull as new (secret: never) => typeof runtime.JsonNull,
|
||||
AnyNull: runtime.NullTypes.AnyNull as new (secret: never) => typeof runtime.AnyNull
|
||||
};
|
||||
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||
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)
|
||||
*
|
||||
* @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)
|
||||
*
|
||||
* @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`
|
||||
*
|
||||
* @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 = {
|
||||
User: 'User',
|
||||
Profile: 'Profile',
|
||||
Address: 'Address',
|
||||
Session: 'Session',
|
||||
Account: 'Account',
|
||||
UserRole: 'UserRole',
|
||||
Verification: 'Verification',
|
||||
Proclamation: 'Proclamation',
|
||||
UserProclamations: 'UserProclamations',
|
||||
SolfeggioFrequency: 'SolfeggioFrequency',
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
SubscriptionLevel: 'SubscriptionLevel'
|
||||
} as const;
|
||||
User: 'User',
|
||||
Profile: 'Profile',
|
||||
Address: 'Address',
|
||||
Session: 'Session',
|
||||
Account: 'Account',
|
||||
UserRole: 'UserRole',
|
||||
Verification: 'Verification',
|
||||
Proclamation: 'Proclamation',
|
||||
ProclamationGroup: 'ProclamationGroup',
|
||||
UserProclamations: 'UserProclamations',
|
||||
SolfeggioFrequency: 'SolfeggioFrequency',
|
||||
ProclamationCategory: 'ProclamationCategory',
|
||||
LicenseKey: 'LicenseKey',
|
||||
ApiKey: 'ApiKey',
|
||||
ColorPalette: 'ColorPalette',
|
||||
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
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const);
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const)
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
|
||||
export type TransactionIsolationLevel =
|
||||
(typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel];
|
||||
|
||||
export const UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
roleId: 'roleId'
|
||||
} as const;
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
roleId: 'roleId'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum];
|
||||
|
||||
export const ProfileScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
phoneNumber: 'phoneNumber',
|
||||
ghlContactId: 'ghlContactId'
|
||||
} as const;
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
email: 'email',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
phoneNumber: 'phoneNumber',
|
||||
ghlContactId: 'ghlContactId'
|
||||
} as const
|
||||
|
||||
export type ProfileScalarFieldEnum = (typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum]
|
||||
|
||||
export type ProfileScalarFieldEnum =
|
||||
(typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum];
|
||||
|
||||
export const AddressScalarFieldEnum = {
|
||||
id: 'id',
|
||||
profileId: 'profileId',
|
||||
street: 'street',
|
||||
city: 'city',
|
||||
state: 'state',
|
||||
postalCode: 'postalCode',
|
||||
country: 'country',
|
||||
type: 'type',
|
||||
isDefaultMailing: 'isDefaultMailing',
|
||||
isDefaultBilling: 'isDefaultBilling',
|
||||
isDefaultShipping: 'isDefaultShipping'
|
||||
} as const;
|
||||
id: 'id',
|
||||
profileId: 'profileId',
|
||||
street: 'street',
|
||||
city: 'city',
|
||||
state: 'state',
|
||||
postalCode: 'postalCode',
|
||||
country: 'country',
|
||||
type: 'type',
|
||||
isDefaultMailing: 'isDefaultMailing',
|
||||
isDefaultBilling: 'isDefaultBilling',
|
||||
isDefaultShipping: 'isDefaultShipping'
|
||||
} as const
|
||||
|
||||
export type AddressScalarFieldEnum = (typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum]
|
||||
|
||||
export type AddressScalarFieldEnum =
|
||||
(typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum];
|
||||
|
||||
export const SessionScalarFieldEnum = {
|
||||
id: 'id',
|
||||
expiresAt: 'expiresAt',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
userId: 'userId'
|
||||
} as const;
|
||||
id: 'id',
|
||||
expiresAt: 'expiresAt',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
ipAddress: 'ipAddress',
|
||||
userAgent: 'userAgent',
|
||||
userId: 'userId'
|
||||
} as const
|
||||
|
||||
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
|
||||
|
||||
export type SessionScalarFieldEnum =
|
||||
(typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum];
|
||||
|
||||
export const AccountScalarFieldEnum = {
|
||||
id: 'id',
|
||||
accountId: 'accountId',
|
||||
providerId: 'providerId',
|
||||
userId: 'userId',
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'refreshToken',
|
||||
idToken: 'idToken',
|
||||
accessTokenExpiresAt: 'accessTokenExpiresAt',
|
||||
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
|
||||
scope: 'scope',
|
||||
password: 'password',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const;
|
||||
id: 'id',
|
||||
accountId: 'accountId',
|
||||
providerId: 'providerId',
|
||||
userId: 'userId',
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'refreshToken',
|
||||
idToken: 'idToken',
|
||||
accessTokenExpiresAt: 'accessTokenExpiresAt',
|
||||
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
|
||||
scope: 'scope',
|
||||
password: 'password',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
|
||||
|
||||
export type AccountScalarFieldEnum =
|
||||
(typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum];
|
||||
|
||||
export const UserRoleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name'
|
||||
} as const;
|
||||
id: 'id',
|
||||
name: 'name'
|
||||
} as const
|
||||
|
||||
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
|
||||
|
||||
export type UserRoleScalarFieldEnum =
|
||||
(typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum];
|
||||
|
||||
export const VerificationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
identifier: 'identifier',
|
||||
value: 'value',
|
||||
expiresAt: 'expiresAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const;
|
||||
id: 'id',
|
||||
identifier: 'identifier',
|
||||
value: 'value',
|
||||
expiresAt: 'expiresAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
|
||||
|
||||
export type VerificationScalarFieldEnum =
|
||||
(typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum];
|
||||
|
||||
export const ProclamationScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
content: 'content',
|
||||
solfeggioFrequencyId: 'solfeggioFrequencyId',
|
||||
categoryId: 'categoryId',
|
||||
subscriptionLevelId: 'subscriptionLevelId'
|
||||
} as const;
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
content: 'content',
|
||||
solfeggioFrequencyId: 'solfeggioFrequencyId',
|
||||
categoryId: 'categoryId',
|
||||
groupId: 'groupId'
|
||||
} 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 = {
|
||||
userId: 'userId',
|
||||
proclamationId: 'proclamationId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const;
|
||||
userId: 'userId',
|
||||
proclamationId: 'proclamationId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type UserProclamationsScalarFieldEnum = (typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum]
|
||||
|
||||
export type UserProclamationsScalarFieldEnum =
|
||||
(typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum];
|
||||
|
||||
export const SolfeggioFrequencyScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
value: 'value'
|
||||
} as const;
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
value: 'value'
|
||||
} as const
|
||||
|
||||
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
|
||||
|
||||
export type SolfeggioFrequencyScalarFieldEnum =
|
||||
(typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum];
|
||||
|
||||
export const ProclamationCategoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
sortOrder: 'sortOrder',
|
||||
parentId: 'parentId'
|
||||
} as const;
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
sortOrder: 'sortOrder'
|
||||
} as const
|
||||
|
||||
export type ProclamationCategoryScalarFieldEnum =
|
||||
(typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum];
|
||||
export type 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 = {
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
active: 'active',
|
||||
level: 'level',
|
||||
userId: 'userId'
|
||||
} as const;
|
||||
id: 'id',
|
||||
key: 'key',
|
||||
active: 'active',
|
||||
createdAt: 'createdAt',
|
||||
lastValidatedAt: 'lastValidatedAt',
|
||||
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 = {
|
||||
id: 'id',
|
||||
keyPrefix: 'keyPrefix',
|
||||
keyHash: 'keyHash',
|
||||
createdAt: 'createdAt',
|
||||
expiresAt: 'expiresAt',
|
||||
revokedAt: 'revokedAt'
|
||||
} as const;
|
||||
id: 'id',
|
||||
keyPrefix: 'keyPrefix',
|
||||
keyHash: 'keyHash',
|
||||
createdAt: 'createdAt',
|
||||
expiresAt: 'expiresAt',
|
||||
revokedAt: 'revokedAt'
|
||||
} as const
|
||||
|
||||
export type ApiKeyScalarFieldEnum =
|
||||
(typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum];
|
||||
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
|
||||
|
||||
export const SubscriptionLevelScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description'
|
||||
} as const;
|
||||
|
||||
export type SubscriptionLevelScalarFieldEnum =
|
||||
(typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum];
|
||||
export const ColorPaletteScalarFieldEnum = {
|
||||
solfeggioFrequencyId: 'solfeggioFrequencyId',
|
||||
colors: 'colors'
|
||||
} as const
|
||||
|
||||
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
|
||||
|
||||
|
||||
export const AuditLogScalarFieldEnum = {
|
||||
id: 'id',
|
||||
createdAt: 'createdAt',
|
||||
userId: 'userId',
|
||||
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 const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const;
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} 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 = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const;
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const;
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} 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];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
@@ -7,19 +8,23 @@
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
export type * from './models/User.ts';
|
||||
export type * from './models/Profile.ts';
|
||||
export type * from './models/Address.ts';
|
||||
export type * from './models/Session.ts';
|
||||
export type * from './models/Account.ts';
|
||||
export type * from './models/UserRole.ts';
|
||||
export type * from './models/Verification.ts';
|
||||
export type * from './models/Proclamation.ts';
|
||||
export type * from './models/UserProclamations.ts';
|
||||
export type * from './models/SolfeggioFrequency.ts';
|
||||
export type * from './models/ProclamationCategory.ts';
|
||||
export type * from './models/ProclamationCategorySubscriptionLevel.ts';
|
||||
export type * from './models/LicenseKey.ts';
|
||||
export type * from './models/ApiKey.ts';
|
||||
export type * from './models/SubscriptionLevel.ts';
|
||||
export type * from './commonInputTypes.ts';
|
||||
export type * from './models/User.ts'
|
||||
export type * from './models/Profile.ts'
|
||||
export type * from './models/Address.ts'
|
||||
export type * from './models/Session.ts'
|
||||
export type * from './models/Account.ts'
|
||||
export type * from './models/UserRole.ts'
|
||||
export type * from './models/Verification.ts'
|
||||
export type * from './models/Proclamation.ts'
|
||||
export type * from './models/ProclamationGroup.ts'
|
||||
export type * from './models/UserProclamations.ts'
|
||||
export type * from './models/SolfeggioFrequency.ts'
|
||||
export type * from './models/ProclamationCategory.ts'
|
||||
export type * from './models/LicenseKey.ts'
|
||||
export type * from './models/ApiKey.ts'
|
||||
export type * from './models/ColorPalette.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'
|
||||
+1424
-1698
File diff suppressed because it is too large
Load Diff
+1352
-1616
File diff suppressed because it is too large
Load Diff
+1010
-1177
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
+1358
-1520
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1247
-1501
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
+2070
-2307
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
Reference in New Issue
Block a user