Compare commits

..
43 Commits
Author SHA1 Message Date
NeilClack e86e90137b Merge pull request 'Dev' (#1) from dev into main
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 44s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 23s
Reviewed-on: #1
2026-09-30 04:29:28 +00:00
NeilClack 2aa6669007 Migrate actions to new runner 2026-09-29 23:28:50 -05:00
NeilClack 87934dfd42 chore(vscode): remove debugger configuration 2026-09-29 15:22:03 -05:00
NeilClack 220ff92e78 Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid 2026-09-29 15:22:02 -05:00
NeilClack cc4aa8717e chore(vscode): fix dev-server task matcher and add debuggable-Chrome task
Disable Vite's ANSI output (NO_COLOR/FORCE_COLOR) so the background-task
problem matcher's endsPattern reliably matches, fixing the
'Error exists after running preLaunchTask' failure. Add a
'Launch Debuggable Chrome' task (:9222, persistent ~/.chrome-rw-debug
profile) and an attach compound so you can debug the frontend in a Chrome
you control and stay logged in. Update DEBUGGING.md.
2026-09-29 13:41:18 -05:00
NeilClack e9f94b72ee chore: prettier-format captured Portal fixtures 2026-09-29 13:29:55 -05:00
NeilClack 04e1f79f37 chore(vscode): separate backend and frontend debug configs
Backend configs run Vite directly (not via npm run dev) so the Node
inspector owns the process that executes SSR code, with source maps
enabled for .ts server files. Frontend configs launch/attach Chrome.
Adds a Node-inspector dev task, a Full Stack compound, and DEBUGGING.md
documenting the Safari limitation and workflows.
2026-09-29 13:29:55 -05:00
NeilClack bbf4ac12ee Portal: calendar-style weekly schedule grid, responsive on mobile
Replace the stacked per-day lists with a Google-Calendar-style WeekSchedule:
day columns side by side over a shared, sticky time gutter. Click or drag an
empty slot to create a block at that time; click a block to edit it. The
daily schedule reuses the same grid with a single 'Every day' column so both
modes look consistent. On narrow screens the row scrolls horizontally with
short day labels and the gutter pinned, keeping it usable on phones. Also
change the type switcher and copy dialog to enhanced form submissions (no
full-page reload) so they respond immediately. Removes the now-unused
DaySchedule component.
2026-09-29 13:09:48 -05:00
NeilClack bf465eca60 docs: record Phase 5 verification results 2026-09-29 12:48:26 -05:00
NeilClack 6a3ecaf734 docs: Phase 5 finalize contract, add release notes, sync fixtures 2026-09-29 12:47:33 -05:00
NeilClack e6021a6345 test: Phase 5 verification scripts and opt-in e2e integration spec
Adds scripts to seed scenario data, create Proclamations, snapshot schedules,
drive the Kiosk-facing HTTP endpoints, and an opt-in (P5_INTEGRATION=1)
Vitest spec exercising create/type/copy against a scratch database. The
integration spec is skipped by default so the normal suite needs no DB.
2026-09-29 12:46:04 -05:00
NeilClack be303dcd9c docs: finalize Phase 4 schedule copy in the contract 2026-09-29 10:50:18 -05:00
NeilClack fcb5db458b Portal: Copy to… schedule UI
Adds a Copy to… action on a system's schedule with a multi-select of the
account's other systems, labelled by System Name (fallback: key), excluding
the source. Targets that already have a schedule are flagged and require an
explicit Overwrite & copy confirmation. Shows a summary of the systems
updated after success.
2026-09-29 10:49:15 -05:00
NeilClack 242be5d241 Portal: copy action and hasSchedule flag in the schedule loader
Adds a session-authenticated copy action that scopes the source and targets
to the caller's License Keys and returns a summary of the systems updated.
The loader now reports hasSchedule per system so the Copy UI can warn before
overwriting. Adds action tests (auth, cross-account, summary, error).
2026-09-29 10:49:10 -05:00
NeilClack 68a979f1a1 Portal: transactional schedule copy service
copySchedule copies one License Key's Schedule to one or more other keys by
value in a single Prisma transaction (all targets or none), assigning fresh
Block IDs and preserving each block's dynamicAudio and Proclamation order.
Source/targets are scoped to the caller's account; self-copy and empty
target lists are rejected. The resulting schedule is run through the shared
validator before commit. Adds tests: daily/weekly copy, overwrite, multiple
targets, rollback, cross-account rejection, new IDs, changed version/ETag,
copy independence, and Kiosk-payload parity with the shared fixtures.
2026-09-29 10:49:06 -05:00
NeilClack f6c937b892 docs: document Phase 3 schedule endpoint, model, and resolved open items 2026-09-29 10:35:44 -05:00
NeilClack 9a2888af36 Portal: per-system daily/weekly schedule editor
System picker lists the account's License Keys labelled by System Name
(fallback: key). Schedule type selector (Daily|Weekly) with a confirmation
before switching a populated schedule (daily->weekly prefills all seven
days; otherwise clears). Weekly shows a per-day editor. Blocks use
TimeInput, allow adjacent/midnight-crossing ranges (labelled 'ends next
day'), block empty blocks client-side, default dynamic audio on, and show
title/category/group only. Adds gap helper text and an informational
kiosk-timezone note (browser fallback; no external/paid dependency).
2026-09-29 10:28:05 -05:00
NeilClack 3bb8e0189b Portal: per-license schedule service, shared validator, and key-only GET
Implements the shared validateSchedule validator (daily + weekly, midnight
spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a
per-License-Key schedule service (get/set type, CRUD blocks, weekly
prefill with confirmation), the content-hash version/ETag serializer, the
new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null),
and additive evolution of the legacy GET /api/v2/schedule. Adds validator,
serializer (against docs/fixtures), endpoint ETag/304, and service tests.
2026-09-29 10:23:10 -05:00
NeilClack 138bf03ea9 Portal: move Schedule to per-License-Key with daily/weekly and Block.day
Adds Schedule.licenseKeyId (unique, nullable) and Schedule.type, makes the
legacy userId optional/non-unique, adds Block.day and flips Block
dynamicAudio default to true. The migration backfills each existing
per-user schedule onto that account's oldest License Key as a daily
schedule, preserves unmappable schedules (licenseKeyId NULL) with a
NOTICE, and is reversible. Tested up/down against a scratch schema.
2026-09-29 10:11:21 -05:00
NeilClack 25b5eeb144 docs: document System Name PUT endpoint and validation fields in OpenAPI 2026-09-29 09:59:17 -05:00
NeilClack 3c49f425d8 Portal: display System Name read-only on dashboard and admin lists
Show the System Name where license keys are listed, falling back to an
'Unnamed system' indication when unset. No rename UI or mutation path.
2026-09-29 09:51:58 -05:00
NeilClack 8412dfb69c Portal: System Name PUT endpoint and license-validation status
Add PUT /api/v2/licenses/[licenseKey]/system-name (200/400 invalid_name/
404 unknown_license/409 name_taken, idempotent) and extend POST
/api/v2/license with an optional systemName and additive systemNameStatus
(ok/unchanged/conflict/invalid). Validation always succeeds; an omitted
field never changes or clears a name and empty/whitespace is invalid.
Adds server and helper tests covering trimming, bounds, case-insensitive
uniqueness, cross-account reuse, 404, and idempotency.
2026-09-29 09:51:54 -05:00
NeilClack 1d76e8dff9 Portal: add nullable System Name to LicenseKey with per-account unique index
Adds systemName (as typed, trimmed) and systemNameLower (case-folded) to
LicenseKey plus a unique index on (userId, systemNameLower) so names are
case-insensitively unique per account when non-null. Additive and
reversible; migration tested up/down. Regenerates the Prisma client.
2026-09-29 09:51:48 -05:00
NeilClack 179df1e252 Portal: 12-hour schedule times and dynamicAudio default on
Use TimeInput for Block start/end and show the day timeline axis with
AM/PM, so no 24-hour time is displayed in the schedule UI. New Blocks now
default dynamicAudio to true in the modal and server-side when the field
is absent; existing stored Blocks are untouched.
2026-09-29 08:53:22 -05:00
NeilClack 35558b0652 Portal: add reusable 12-hour TimeInput component
TimeInput displays and accepts AM/PM times, offers the 5-minute options as
suggestions, and allows typing any minute 00-59; its value model is a 24h
HH:MM string. Adds isomorphic parseTimeInput/hmToMinutes/fiveMinuteOptions
helpers and unit + component tests for them.
2026-09-29 08:53:17 -05:00
NeilClack 6c89a89050 docs: add Phase 0 schedule API/data contract and fixtures
Add docs/schedule-api-contract.md (current state, time semantics, schedule
data model, validation rules, endpoints, sync behavior, System Name rules,
dynamic audio, backward compatibility, per-phase touchpoints, open items,
changelog) and docs/fixtures/*.json (daily, weekly, none, midnight-crossing,
adjacent, dynamic-audio, and legacy v2 payloads). Kept byte-identical with
the radiantwave repo.
2026-09-29 08:36:31 -05:00
NeilClack dca9c54d83 Merge branch 'dev'
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 3m23s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 41s
2026-09-11 19:07:21 -05:00
NeilClack c19c13b192 Document dynamic audio and add a schedule response example 2026-09-11 18:45:59 -05:00
NeilClack ec826093eb Expose dynamic audio on the v2 schedule API 2026-09-11 18:41:57 -05:00
NeilClack 96a1342c10 Add Enable Dynamic Audio option to schedule blocks 2026-09-11 18:41:53 -05:00
NeilClack 468a65a688 Add dynamic audio flag to schedule blocks 2026-09-11 18:41:46 -05:00
NeilClack e934a98f00 Use a 12-hour clock with AM/PM selects in the block modal 2026-09-11 18:30:26 -05:00
NeilClack 15bb51582b Move Cancel left and rename Back to Accept in the block modal 2026-09-11 18:30:08 -05:00
NeilClack 46d7cf66b8 Render overnight blocks across midnight in the day view 2026-09-11 18:24:54 -05:00
NeilClack 177b7bb13a Flag overnight blocks in the v2 schedule API 2026-09-11 18:24:51 -05:00
NeilClack 750a9286c9 Support overnight blocks in schedule validation 2026-09-11 18:24:48 -05:00
NeilClack c177e7953d Document the v2 schedule endpoint 2026-09-11 18:16:08 -05:00
NeilClack 5f4116b8e8 Add schedule page with block editor and proclamation browser 2026-09-11 18:16:06 -05:00
NeilClack 545fb22910 Add v2 schedule API endpoint 2026-09-11 18:16:04 -05:00
NeilClack 4922e941ae Add schedule validation and service layer 2026-09-11 18:16:00 -05:00
NeilClack e6b492a335 Add schedule data model and migration 2026-09-11 18:15:57 -05:00
NeilClack 051520b941 Fix page refresh issue
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 2m36s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 34s
2026-09-11 16:05:43 -05:00
NeilClack 64715c91d3 Update workflow
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 4m6s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 1m50s
2026-09-11 15:36:03 -05:00
79 changed files with 13722 additions and 89 deletions
+11 -5
View File
@@ -6,12 +6,15 @@ on:
jobs: jobs:
build-and-push: build-and-push:
runs-on: build-server runs-on: orion-lxc
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
git clone ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/portal.git . rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
- name: Build image - name: Build image
run: | run: |
@@ -34,12 +37,15 @@ jobs:
migrate: migrate:
needs: build-and-push needs: build-and-push
runs-on: build-server runs-on: orion-lxc
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
git clone ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/portal.git . 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 - name: Setup Node
uses: https://github.com/actions/setup-node@v4 uses: https://github.com/actions/setup-node@v4
@@ -53,4 +59,4 @@ jobs:
env: env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_URL: ${{ secrets.DATABASE_URL }}
NODE_EXTRA_CA_CERTS: ./digital-ocean.crt NODE_EXTRA_CA_CERTS: ./digital-ocean.crt
run: npm run deploy run: npm run deploy
-27
View File
@@ -1,27 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Backend (server-side code)",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"autoAttachChildProcesses": true
},
{
"name": "Frontend (client-side code)",
"type": "chrome",
"request": "launch",
"preLaunchTask": "Start Dev Server",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"vite:///src/*": "${webRoot}/src/*"
}
}
]
}
-1
View File
@@ -1 +0,0 @@
{}
-26
View File
@@ -1,26 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Dev Server",
"type": "shell",
"command": "npm run dev -- --port 5173 --strictPort",
"isBackground": true,
"problemMatcher": {
"pattern": [
{
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^.*VITE.*v",
"endsPattern": "^.*localhost:.*$"
}
}
}
]
}
+20
View File
@@ -97,6 +97,26 @@ Requires API key.
--- ---
# Schedule (v2)
Endpoint:
GET /api/v2/schedule?email=<email>&licenseKey=<key>
Authenticates with the paired `email` + `licenseKey` query parameters and
validates only that the key belongs to the account (not that the license is
active/entitled). Returns the user's Blocks with start/end times and the IDs of
the selected Proclamations (content is fetched separately via
`/api/v2/proclamations`). Each block also carries `dynamicAudio`; when true, the
kiosk enables dynamic audio for the block's duration, overriding any audio
selections made on the device while the block is active. Overnight blocks (end
clock time before start) are supported and flagged with `overnight: true` plus a
wrapping `durationMinutes`. Rate limited to 10 requests per minute per IP.
Failure messages follow the existing v2 shape:
`not-authorized` or `this key doesn't appear to belong to you`.
---
# GHL Account Creation Webhook # GHL Account Creation Webhook
Endpoint: Endpoint:
+59
View File
@@ -0,0 +1,59 @@
{
"version": "sched-adjacent-0001",
"type": "daily",
"daily": [
{
"id": "dd111111-1111-4111-8111-111111111111",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
},
{
"id": "dd222222-2222-4222-8222-222222222222",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "08:30",
"endMinutes": 510,
"overnight": false,
"durationMinutes": 90,
"proclamationIds": [
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b",
"33333333-3333-4333-8333-333333333333"
],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "dd111111-1111-4111-8111-111111111111",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"]
},
{
"id": "dd222222-2222-4222-8222-222222222222",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "08:30",
"endMinutes": 510,
"overnight": false,
"durationMinutes": 90,
"dynamicAudio": false,
"proclamationIds": [
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b",
"33333333-3333-4333-8333-333333333333"
]
}
]
}
+59
View File
@@ -0,0 +1,59 @@
{
"version": "sched-daily-0001",
"type": "daily",
"daily": [
{
"id": "11111111-1111-4111-8111-111111111111",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "09:00",
"endMinutes": 540,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
],
"dynamicAudio": true
},
{
"id": "22222222-2222-4222-8222-222222222222",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "11111111-1111-4111-8111-111111111111",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "09:00",
"endMinutes": 540,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
]
},
{
"id": "22222222-2222-4222-8222-222222222222",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
}
]
}
+53
View File
@@ -0,0 +1,53 @@
{
"version": "sched-dynamic-0001",
"type": "daily",
"daily": [
{
"id": "ee111111-1111-4111-8111-111111111111",
"startTime": "08:00",
"startMinutes": 480,
"endTime": "10:00",
"endMinutes": 600,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
},
{
"id": "ee222222-2222-4222-8222-222222222222",
"startTime": "20:00",
"startMinutes": 1200,
"endTime": "22:00",
"endMinutes": 1320,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "ee111111-1111-4111-8111-111111111111",
"startTime": "08:00",
"startMinutes": 480,
"endTime": "10:00",
"endMinutes": 600,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"]
},
{
"id": "ee222222-2222-4222-8222-222222222222",
"startTime": "20:00",
"startMinutes": 1200,
"endTime": "22:00",
"endMinutes": 1320,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": false,
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"]
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"email": "jane@example.com",
"blocks": [
{
"id": "11111111-1111-4111-8111-111111111111",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "09:00",
"endMinutes": 540,
"overnight": false,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
]
},
{
"id": "22222222-2222-4222-8222-222222222222",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
}
]
}
@@ -0,0 +1,59 @@
{
"version": "sched-midnight-0001",
"type": "daily",
"daily": [
{
"id": "cc111111-1111-4111-8111-111111111111",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
],
"dynamicAudio": true
},
{
"id": "cc222222-2222-4222-8222-222222222222",
"startTime": "09:00",
"startMinutes": 540,
"endTime": "21:00",
"endMinutes": 1260,
"overnight": false,
"durationMinutes": 720,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
"dynamicAudio": false
}
],
"weekly": {},
"blocks": [
{
"id": "cc111111-1111-4111-8111-111111111111",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"dynamicAudio": true,
"proclamationIds": [
"b40fdcb9-703c-420e-af32-a081964c6f08",
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
]
},
{
"id": "cc222222-2222-4222-8222-222222222222",
"startTime": "09:00",
"startMinutes": 540,
"endTime": "21:00",
"endMinutes": 1260,
"overnight": false,
"durationMinutes": 720,
"dynamicAudio": false,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"version": "sched-none-0001",
"type": null,
"daily": [],
"weekly": {},
"blocks": []
}
+53
View File
@@ -0,0 +1,53 @@
{
"version": "7f53efde17d291a1ecf604e266d3803d1495b986",
"type": "daily",
"daily": [
{
"id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
},
{
"id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
}
],
"weekly": {},
"blocks": [
{
"id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
},
{
"id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "20:30",
"endMinutes": 1230,
"overnight": false,
"durationMinutes": 150,
"dynamicAudio": false,
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
}
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": "6de852ba72ce1ab3052150640730ee87a7d460b5",
"type": "weekly",
"daily": [],
"weekly": {
"mon": [
{
"id": "a1054b78-480a-4e87-8465-aac8d677c666",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "07:00",
"endMinutes": 420,
"overnight": false,
"durationMinutes": 60,
"dynamicAudio": true,
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
},
{
"id": "25979ec9-f7da-4aa4-864f-f74b1a2da78b",
"startTime": "07:00",
"startMinutes": 420,
"endTime": "08:30",
"endMinutes": 510,
"overnight": false,
"durationMinutes": 90,
"dynamicAudio": true,
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
}
],
"tue": [],
"wed": [],
"thu": [],
"fri": [],
"sat": [],
"sun": [
{
"id": "e2237d98-834c-433b-8979-b56ddad2ef7a",
"startTime": "23:00",
"startMinutes": 1380,
"endTime": "01:00",
"endMinutes": 60,
"overnight": true,
"durationMinutes": 120,
"dynamicAudio": true,
"proclamationIds": ["fcd5a291-3d6e-4678-9d85-9b550e80f1c7"]
}
]
},
"blocks": []
}
+63
View File
@@ -0,0 +1,63 @@
{
"version": "sched-weekly-0001",
"type": "weekly",
"daily": [],
"weekly": {
"mon": [
{
"id": "a1111111-1111-4111-8111-111111111111",
"startTime": "06:00",
"startMinutes": 360,
"endTime": "08:00",
"endMinutes": 480,
"overnight": false,
"durationMinutes": 120,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
],
"tue": [],
"wed": [
{
"id": "a2222222-2222-4222-8222-222222222222",
"startTime": "12:00",
"startMinutes": 720,
"endTime": "13:00",
"endMinutes": 780,
"overnight": false,
"durationMinutes": 60,
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"],
"dynamicAudio": true
}
],
"thu": [],
"fri": [
{
"id": "a3333333-3333-4333-8333-333333333333",
"startTime": "18:00",
"startMinutes": 1080,
"endTime": "22:00",
"endMinutes": 1320,
"overnight": false,
"durationMinutes": 240,
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
"dynamicAudio": false
}
],
"sat": [],
"sun": [
{
"id": "a4444444-4444-4444-8444-444444444444",
"startTime": "23:00",
"startMinutes": 1380,
"endTime": "01:00",
"endMinutes": 60,
"overnight": true,
"durationMinutes": 120,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
]
},
"blocks": []
}
+336 -1
View File
@@ -31,6 +31,8 @@ tags:
description: Proclamation content, optionally scoped by license entitlement. description: Proclamation content, optionally scoped by license entitlement.
- name: License - name: License
description: License key validation. description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations - name: Automations
description: Signed machine-to-machine webhooks. description: Signed machine-to-machine webhooks.
paths: paths:
@@ -230,6 +232,218 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorMessage' $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: /api/v2/automations/create/account:
post: post:
tags: [Automations] tags: [Automations]
@@ -426,9 +640,16 @@ components:
type: string type: string
format: email format: email
maxLength: 254 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: LicenseResponse:
type: object type: object
required: [key, email_address] required: [key, email_address, systemNameStatus]
properties: properties:
key: key:
type: string type: string
@@ -436,6 +657,120 @@ components:
email_address: email_address:
type: string type: string
format: email 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: OnboardingRequest:
type: object type: object
required: [email] required: [email]
+619
View File
@@ -0,0 +1,619 @@
# Schedule API & Data Contract
Status: Phase 0 (contract only — no application code). This document is the
authoritative API/data contract for the multi-phase scheduler overhaul. It is
kept **byte-identical** in both repositories:
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/schedule-api-contract.md`
- `portal` (SvelteKit/Node) — `docs/schedule-api-contract.md`
Any change is applied to both copies in the same step, with a dated line
appended to the [Changelog](#changelog).
Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule,
License Key, System Name) are used with the meanings defined in the scheduler
overhaul brief.
---
## Current state
This section reflects the code as it exists at the start of Phase 0. It is
descriptive, not aspirational; the target behaviour is defined in the later
sections.
### Kiosk (`radiantwave`)
- **License validation.** `POST /api/v2/license` with body
`{"license_key": "...", "email_address": "..."}`. Implemented in
`src/network/api_client.cpp` (`validateLicense`, `parseLicenseJson`) and
consumed by `src/network/license_service.cpp` (`validateStoredLicense`,
`markLicenseVerified`, `isLicenseWithinGracePeriod`). A 2xx response is the
device's "subscribed" flag; a 7-day offline grace window is stored as
`license_last_verified` in `system_kv` (`src/db/db_keys.h`). Response body
currently read: `{key, email_address}`; additional fields are ignored.
- **Schedule fetch.** `GET /api/v2/schedule?email=<email>&licenseKey=<key>`
(`src/network/api_client.cpp`: `fetchSchedule`, `parseScheduleJson`;
`PATH_SCHEDULE = "/schedule"`). Response shape read by the Kiosk:
`{email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes,
dynamicAudio, proclamationIds}]}`. `dynamicAudio` is optional on the wire and
defaults to `true` in `parseScheduleJson`. Missing/blank credentials cause an
early `false`.
- **Schedule persistence.** SQLite tables `schedule_blocks` and
`schedule_block_proclamations` (`src/db/database_manager.cpp:185`,
`replaceScheduleBlocks` at `:1105`, `getScheduleBlocks` at `:1169`). Full
replace; block `sort_order` and per-block proclamation `position` preserved.
No day-of-week column and no schedule `type`; the model is implicitly daily.
- **Schedule sync.** `syncScheduleFromApi` (`src/network/proclamation_sync.cpp:172`)
is invoked only as a **best-effort** side effect of
`syncProclamationsFromApi` (`:168`). There is no boot sync, no network-up
sync, no periodic timer, and no conditional GET (`If-None-Match`/ETag).
`syncProclamationsFromApi` is called from `Home::syncProclamationsIfEmpty`
(`src/home.cpp:461`) and `ProclamationOptionsPage::onPush`
(`src/proclamation_options_page.cpp:49`). There is no explicit atomic
schedule cache file; the SQLite database is the durable store.
- **Schedule playback.** `src/scroller_page.cpp` selects the active block by
comparing `timezone::currentLocalMinutes(cfg.timezone)`
(`src/timezone_helper.h`) against each block via
`scroller::minuteInScheduleBlock` (`src/scroller_streams.h:31`). Block
selection is daily-only (no day-of-week). An active block's ordered
`proclamation_ids` replace the local selection
(`src/scroller_streams.cpp:57`). `effectiveDynamicAudio()`
(`src/scroller_page.cpp:302`) forces dynamic audio when the active block has
`dynamic_audio == true`, otherwise the device-wide
`config.audio.dynamic_audio` applies. A block change is latched in
`scheduleChangePending_` and applied at the next proclamation boundary.
- **Settings persistence.** `~/.local/radiantwave/config.yaml` (YAML) via
`ConfigManager` (`src/config/config_manager.cpp`, `src/config/yaml_parser.cpp`,
schema in `src/config/app_config.h`): `timezone`, `audio.dynamic_audio`,
`proclamation.schedule_active` (Kiosk-local on/off toggle, default `false`).
Identity lives in SQLite `system_kv` (`email_address`, `license_key`,
`license_last_verified`). There is no System Name anywhere.
- **Conventions.** `API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"`
(`src/network/api_client.h`). Kiosk API knowledge is mirrored in
`openapi.yaml` at the repo root.
### Portal (`portal`)
- **Framework/tooling.** SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4;
Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate
(`prisma/schema.prisma`, `prisma/migrations/`, `npm run migrate`,
`npm run deploy`; config in `prisma.config.ts`). API routes under
`src/routes/api/v1/` (API-key gated) and `src/routes/api/v2/`
(unauthenticated). v2 helpers: `src/lib/server/v2-helpers.ts`
(`jsonOk`, `jsonError`, `readJsonBody`, `rateLimited`, `clientIp`); errors
are `{ "message": "<text>" }`.
- **License validation.** `POST /api/v2/license`
(`src/routes/api/v2/license/+server.ts`): validates `license_key` +
`email_address`, requires the key be active and owned by the account, stamps
`LicenseKey.lastValidatedAt`, returns `serializeLicense({key, emailAddress})`
(`src/lib/server/v2-serializers.ts`). A v1 variant exists
(`src/routes/api/v1/license/+server.ts`).
- **Schedule storage.** `Schedule` is **per user** (`userId String @unique`,
`prisma/schema.prisma:263`), daily-only, `0..4` `Block`s. `Block`
(`:275`) stores `startMinutes`/`endMinutes`, `dynamicAudio Boolean
@default(false)`, and ordered `BlockProclamations` (`sortOrder`). The schema
comment states overnight ranges are disallowed in v1, yet the serializer and
Kiosk both support `overnight` — a known inconsistency.
- **Schedule service/validation.** `src/lib/server/schedule.ts` (DB
orchestration: `getOrCreateSchedule`, `listBlocks`, `createBlock`,
`updateBlock`, `deleteBlock`) and the isomorphic
`src/lib/schedule-utils.ts` (`MAX_BLOCKS = 4`, `SNAP_MINUTES = 15`,
`validateBlock`, `findOverlap`, `isOvernight`, `blockDuration`,
`blockSegments`, `formatTime`, `toHm`, `snapMinutes`). Validation enforces a
4-block maximum on create, `startMinutes !== endMinutes`, at least one
Proclamation, and no overlaps (overnight-aware via linear segments).
- **Schedule read API.** `GET /api/v2/schedule`
(`src/routes/api/v2/schedule/+server.ts`): auth by paired `email` +
`licenseKey` (legacy alias `key`) query params; checks only that the key
belongs to the email's account (entitlement deliberately not checked); rate
limited 10/min/IP; returns `{email, blocks: [serializeScheduleBlock(...)]}`
where `serializeScheduleBlock` (`src/lib/server/v2-serializers.ts:165`) emits
`{id, startTime, startMinutes, endTime, endMinutes, overnight,
durationMinutes, dynamicAudio, proclamationIds}`.
- **Schedule UI.** Protected user route
`src/routes/(protected)/(user)/schedule/` (`+page.server.ts`,
`+page.svelte`) with `src/lib/components/schedule/DaySchedule.svelte` (single
24h day timeline, drag-to-create) and `BlockModal.svelte`. Dashboard
(`(protected)/(user)/dashboard/`) lists the user's License Keys. No System
Name is displayed or editable.
### Gaps between current state and target
- Schedule is per-user, not per-License-Key.
- No `daily`/`weekly` typing; no day-of-week storage or UI.
- No `version`/ETag, no conditional GET.
- Schedule GET requires `email` + `licenseKey`; there is no license-key-only
route, no `/api/v2/licenses/...` namespace, and no system-name route.
- `dynamicAudio` DB default is `false`; the target default for new Blocks is
`true`.
- No System Name field, validation, or display.
- Sync is opportunistic with no boot/network-up/5-minute cadence and no
atomic cache file.
- Portal stores no timezone (matches target) and shows no informational
geoip-derived timezone.
---
## Time semantics
- Times are **minute precision**, stored as 24-hour `"HH:MM"` strings
representing **local wall-clock** time. AM/PM is display-only.
- Numerators may also be carried as integer minutes-from-midnight
(`startMinutes`/`endMinutes`, `0..1440`) for the Kiosk; the string form is
canonical on the wire.
- A Block is the **half-open interval `[start, end)`**.
- A Block may start on the same minute the previous Block ends (`end 07:00`,
next `start 07:00` is valid and has no gap).
- A Block whose `end` is earlier than its `start` **crosses midnight** and
belongs to its start day. In weekly mode, Sunday's crossing Block spills into
Monday. `start == end` is **invalid** (zero- or full-length ambiguity).
- **Overlaps are invalid**, including spillover from a midnight-crossing Block
into the next day's Blocks (or, in daily mode, into the same list's early
Blocks). Touching boundaries are **not** overlaps.
- Gaps between Blocks are allowed. During a gap the Kiosk plays whatever
Proclamations are selected locally on the Kiosk.
- The Kiosk evaluates Schedules in its own Timezone setting
(`config.timezone`, IANA code). **The payload carries no timezone.** The
Portal may show the user's geoip-derived timezone informationally only.
---
## Schedule data model (JSON)
Each License Key has **at most one** Schedule. `type` is `"daily"` or
`"weekly"` and determines the mode; there is no separate global mode. **No**
Schedule means `type` is `null`.
Suggested shape from the brief, adapted to existing field names (`start`/`end`
→ `startTime`/`endTime`, retaining the existing `startMinutes`/`endMinutes`
integers for the Kiosk):
```json
{
"version": "<opaque string; also used as the ETag>",
"type": "daily" | "weekly" | null,
"daily": [ Block, ... ],
"weekly": { "mon": [Block], "tue": [Block], "wed": [Block],
"thu": [Block], "fri": [Block], "sat": [Block],
"sun": [Block] },
"blocks": [ LegacyBlock, ... ]
}
```
Block:
```json
{
"id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
```
- `version` is an opaque string derived from the Schedule content (see
[Endpoints](#endpoints)). It is implementation-defined and must change
whenever the serialized schedule changes.
- **Only the fields for the active type are populated.** For `type: "daily"`,
`daily` is populated and `weekly` is omitted or empty. For `type: "weekly"`,
`weekly` is populated with all seven keys and `daily` is omitted or empty.
For `type: null`, both are empty/omitted.
- `weekly` always contains all seven lowercase keys `mon`..`sun`; a day with no
Blocks is an empty array.
- `blocks` is the **legacy flattening** retained for older Kiosks (see
[Backward compatibility](#backward-compatibility)). It uses the existing
`LegacyBlock` shape serialized by the current portal
(`{id, startTime, startMinutes, endTime, endMinutes, overnight,
durationMinutes, dynamicAudio, proclamationIds}`). For `type: "weekly"`,
`blocks` is an **empty array** (the server does not know the Kiosk's
timezone and cannot flatten weekly to a single day).
- `proclamationIds` is the ordered play list (order is significant);
duplicates are not meaningful and are de-duplicated by the Portal.
- `dynamicAudio` defaults to `true` for new Blocks created on the Portal.
- Proclamation **content** is never included; the Kiosk fetches content
separately via `/api/v2/proclamations`. Portal users only ever see
title/category/group.
---
## Validation rules
These are enforced by the Portal (authoritative) and defended against by the
Kiosk.
1. A Schedule belongs to exactly one License Key; a License Key has at most one
Schedule.
2. `type` is one of `"daily"`, `"weekly"`, or `null`.
3. A Block has `start` and `end` times; `start == end` is invalid.
4. `end < start` denotes a midnight-crossing Block belonging to its start day.
5. Blocks within a day (and the spillover of a midnight-crossing Block into the
following day) must not overlap. Touching boundaries are allowed.
6. Weekly modes apply rules 3–5 independently per day; a `sun` Block crossing
midnight must not overlap `mon` early Blocks.
7. A Block must contain at least one Proclamation. Empty Blocks are rejected
server-side. The Kiosk defensively **skips invalid Blocks** and treats them
as gaps.
8. Gaps are allowed; during a gap local Kiosk selection applies.
9. `dynamicAudio` is a boolean; absent means `true` for new Blocks.
10. Times are minute-precision `HH:MM` strings; the Portal snaps UI input to
`SNAP_MINUTES` (15) with a `MINUTE_STEP` (5) constraint from the existing
helpers.
11. The existing Portal cap of `MAX_BLOCKS = 4` per day list is **retained**
for Phase 0 (see [Open items](#open-items)).
The Kiosk must not crash on, and must not play, an invalid Block: it skips it.
---
## Endpoints
The API remains **unauthenticated** (no API keys) for v2. This is a known
limitation and is recorded under [Open items](#open-items).
All new endpoints live under the existing versioned namespace
`/api/v2/...` to preserve conventions. v1 routes are unchanged.
### (a) License validation — existing, additive
`POST /api/v2/license`
Request (existing fields unchanged; `systemName` additive and optional):
```json
{
"license_key": "ABCD-EFGH-IJKL-MNOP",
"email_address": "jane@example.com",
"systemName": "Living Room"
}
```
Response (existing fields unchanged; `systemNameStatus` additive):
```json
{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" }
```
- `systemName` is optional. When present it is trimmed; when absent the stored
name is left untouched.
- `systemNameStatus` ∈ `{"ok", "unchanged", "conflict", "invalid"}`:
- `ok` — name was set or updated.
- `unchanged` — omitted, or identical after trim/case-insensitive compare.
- `conflict` — another License Key on the same account already holds the name.
- `invalid` — trimmed length outside 1–40.
- **Validation itself succeeds regardless of name status.** A non-`ok` status
never fails the license check; the Kiosk treats it as advisory.
- Errors and rate limiting follow the existing endpoint (400 messages,
413 body cap, 429 with `Retry-After`).
### (b) Set System Name — Kiosk-facing
`PUT /api/v2/licenses/{licenseKey}/system-name`
Request:
```json
{ "systemName": "Living Room" }
```
Responses:
| Status | Condition | Body |
| --------------------- | ----------------------------------------- | ------------------------------------------------- |
| 200 | Name set/updated (or unchanged) | `{"systemName": "...", "systemNameStatus": "ok"}` |
| 400 `invalid_name` | Trimmed length outside 1–40 | `{"message": "invalid_name"}` |
| 404 `unknown_license` | No License Key matches | `{"message": "unknown_license"}` |
| 409 `name_taken` | Another key on the account holds the name | `{"message": "name_taken"}` |
- The Kiosk is the **only** writer. The Portal has no rename capability.
- The response body uses the existing v2 error convention
(`{"message": "<code>"}`).
- Rate limited at the existing 10 requests/min/IP convention.
### (c) Get Schedule — Kiosk-facing, key-only, conditional
`GET /api/v2/licenses/{licenseKey}/schedule`
- 200 with `ETag: "<version>"` and the payload from
[Schedule data model](#schedule-data-model-json). A License Key with no
Schedule returns 200 with `type: null` and empty lists.
- `304 Not Modified` when the request's `If-None-Match` matches the current
`ETag`.
- `404` (`unknown_license`) when no License Key matches.
- `version` doubles as the ETag value; the header value is the quoted form.
- The response is the **target** payload (`version`, `type`, `daily`, `weekly`,
`blocks`).
- Rate limited at the existing 10 requests/min/IP convention.
### (d) Legacy schedule read — existing, evolved additively
`GET /api/v2/schedule?email=<email>&licenseKey=<key>` remains supported with
its existing auth semantics. Its response is extended **additively** with the
same `version`, `type`, `daily`, and `weekly` fields, while continuing to
populate `email` and `blocks` exactly as before. Old Kiosks ignore the new
fields; new Kiosks may read either endpoint. This endpoint does **not** emit an
ETag and does **not** return 304.
### (e) Portal-internal endpoints (not Kiosk-facing)
These are session-authenticated Portal actions; they are **not** part of the
Kiosk API and are not served by `/api/v2/...`.
- Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on
`(protected)/(user)/schedule` (`?/setType`, `?/createBlock`, `?/updateBlock`,
`?/deleteBlock`), backed by `$lib/server/schedule.ts`.
- **Copy** — implemented in Phase 4 as the `?/copy` action plus
`copySchedule()` in `$lib/server/schedule.ts`. Copies one License Key's
Schedule to one or more other License Keys **by value, in a single database
transaction** (all targets succeed or none). The target `type` becomes the
source's; Block rows get fresh IDs; each block's `dynamicAudio` and ordered
Proclamation references are preserved. The source and every target must
belong to the caller's account; a target equal to the source, an empty
target list, or a source without a schedule are rejected. The resulting
schedule is run through the shared validator before commit. Because Block IDs
are part of the serialized payload, every target's content-derived
`version`/ETag changes, so its Kiosk refetches.
---
## Sync behavior
Target Kiosk sync (defaults; adjust only if the code forces it):
- The Kiosk pulls the Schedule:
- on boot,
- on network-up,
- every 5 minutes.
- Pulls use conditional GET (`If-None-Match` / ETag); a `304` leaves the cache
untouched.
- The last Schedule is cached to disk with an **atomic write** and used offline.
- The schedule on/off toggle is **Kiosk-local** (existing
`proclamation.schedule_active`) and is not synced.
- With no cache and no network, the Kiosk behaves as if schedules are off
(local Proclamation selection applies).
- During gaps, or when schedules are off, the local audio selection applies.
Current-state deltas (to be closed by later phases): no boot/network-up/5-minute
cadence, no ETag, no explicit atomic cache file. These are recorded under
[Touchpoints per phase](#touchpoints-per-phase).
Identity for sync is the License Key. The legacy schedule endpoint additionally
requires the paired email; the new endpoint uses the License Key alone.
---
## System Name rules
- A System Name is a human-readable label for a License Key (e.g.
"Living Room"). It is a **label only** — never used as a reference or key.
- Unique **per account**, compared **case-insensitively**.
- Trimmed, **1–40 characters**.
- Set or changed **only via the Kiosk**. The Portal displays it **read-only**
and has no rename capability.
- The Portal falls back to displaying the License Key when no name is set.
- A Kiosk that omits `systemName` never clears an existing name.
- Wire representations: request field `systemName`; status field
`systemNameStatus` ∈ `{"ok","unchanged","conflict","invalid"}` (see
[Endpoints](#endpoints)).
---
## Dynamic audio
- Each Block has a boolean `dynamicAudio`.
- The Portal defaults it to **`true` for new Blocks**.
- `true` ⇒ the Kiosk uses dynamic audio for that Block.
- `false` ⇒ the Kiosk uses its locally chosen audio.
- During gaps, or when schedules are off, the local audio selection applies
(existing `config.audio.dynamic_audio` / fixed track).
- Kiosk parsing defaults an absent `dynamicAudio` to `true`.
---
## Backward compatibility
All existing endpoints and payloads keep working for Kiosks already in the
field. New fields are **additive**; no v2 field is removed or renamed, and v1
is untouched.
- **Old Kiosk vs new Portal.** An old Kiosk calls
`POST /api/v2/license` (no `systemName`) and
`GET /api/v2/schedule?email&licenseKey`, reading `blocks` and ignoring
unknown fields. The Portal keeps both, keeps the `blocks` shape byte-
compatible, and adds `version`/`type`/`daily`/`weekly` as extra fields. The
old Kiosk is unaffected and ignores `systemNameStatus`.
- **Daily schedules.** For `type: "daily"`, `blocks` is the flattened daily
list; an old Kiosk behaves exactly as today.
- **Weekly schedules.** `blocks` is an **empty array**. An old Kiosk therefore
sees no schedule and falls back to local Proclamation/audio selection. It
cannot interpret weekly schedules; this is accepted and recorded under
[Open items](#open-items).
- **No schedule.** `type: null`, `blocks: []`; both old and new Kiosks fall
back to local selection.
- **Omission of `systemName`** by an old Kiosk never clears an existing name;
`systemNameStatus` is simply ignored.
- The new endpoints are additive; their absence on old Kiosks is irrelevant.
---
## Touchpoints per phase
File/module candidates each later phase will change. Phase numbering is assumed
from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy)
and is recorded under [Open items](#open-items).
### Kiosk (`radiantwave`)
- **Phase 1 (Kiosk scheduler UI, storage, sync, playback)**
- `src/network/api_client.h` / `src/network/api_client.cpp` — parse
`version`/`type`/`daily`/`weekly`, ETag/`If-None-Match`, `PUT`
system-name, `systemNameStatus`.
- `src/network/proclamation_sync.h` / `.cpp` — boot/network-up/5-minute
schedule sync, conditional GET, atomic cache.
- `src/network/license_service.h` / `.cpp` — send `systemName`, handle
`systemNameStatus`.
- `src/db/db_keys.h` — System Name / ETag keys; `src/db/database_manager.h`
/ `.cpp` — schedule `type`/day-of-week storage, cache.
- `src/scroller_page.h` / `.cpp`, `src/scroller_streams.h` / `.cpp` —
day-of-week Block selection, spillover.
- `src/timezone_helper.h` — local day-of-week.
- `src/config/app_config.h`, `src/config/config_manager.cpp`,
`src/config/yaml_parser.cpp` — settings surface.
- New page(s) for setting the System Name; tests in `tests/` registered in
`CMakeLists.txt`.
### Portal (`portal`)
- **Phase 2 (System Name + license validation)**
- `prisma/schema.prisma` + migration — `LicenseKey.systemName` and
case-insensitive uniqueness.
- `src/routes/api/v2/license/+server.ts` — `systemName` /
`systemNameStatus`.
- `src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts` — new.
- `src/lib/server/v2-serializers.ts`, `src/lib/server/v2-helpers.ts`.
- Dashboard read-only display.
- **Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)**
- `prisma/schema.prisma` + migration — Schedule per License Key, `type`,
day-of-week.
- `src/lib/server/schedule.ts`, `src/lib/schedule-utils.ts` — weekly,
per-license orchestration, cross-day validation.
- `src/lib/server/v2-serializers.ts` — `version`/`type`/`daily`/`weekly`.
- `src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts` — new
(ETag/304).
- `src/routes/api/v2/schedule/+server.ts` — additive evolution.
- `src/routes/(protected)/(user)/schedule/*`,
`src/lib/components/schedule/*` — weekly UI, per-license selection.
- Specs: `schedule.spec.ts`, `page.server.spec.ts`, `schedule-utils.spec.ts`.
- **Phase 4 (Schedule copy)**
- Copy service in `src/lib/server/schedule.ts`; copy route/action; UI.
- **All phases** — `docs/openapi.yaml`, this contract, `docs/fixtures/`.
---
## Open items
Recorded ambiguities, conservative choices, and remaining known limitations.
Items 1–21 were resolved or restated during Phases 1–4; items 22–24 are Phase 5
findings. Everything marked "Resolved" is settled for this overhaul.
1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only;
Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here.
2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the
Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no
schedule". Implemented in Phase 3.
3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained.
Phase 3 applies it **per day list** (each of the seven weekly days gets its
own 4-block budget). Whether the cap should be lower is unresolved.
4. **Field naming.** The brief's `start`/`end` were mapped to the existing
`startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk.
5. **`version` derivation.** Resolved in Phase 3: a SHA-1 content hash of the
serialized `{type, daily, weekly}` shape. Stable for equal content, changes
on any mutation.
6. **Unauthenticated key-only access.** The new endpoints authenticate by
License Key alone (per glossary). Anyone who knows a key could read the
schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known
limitation.
7. **`dynamicAudio` default flip.** DB default changed `false` → `true` for
**new** Blocks in Phase 3; existing Blocks keep their stored values.
8. **Per-user → per-License-Key migration.** Resolved in Phase 3: each existing
per-user Schedule is mapped to that account's **oldest** License Key
(`createdAt ASC`), as a daily schedule. Schedules with no owning
user/license are retained with `licenseKeyId NULL` and reported via a
migration `NOTICE` (not dropped).
9. **Cross-day spillover validation.** Implemented in Phase 3: weekly
`sun`→`mon` and daily self-wrap spillover are rejected as overlaps; the
shared `validateSchedule` is authoritative and mirrored client-side.
10. **Account scope for name uniqueness.** "Per account" = the owning User.
Legacy License Keys with only an `email` and no `userId` are an edge case
(written without a per-account uniqueness check).
11. **Geoip timezone display.** Phase 3 shows an informational note that times
run in the **Kiosk's** timezone and uses the **browser-resolved** timezone
as a fallback; no geoip dependency (paid or external) was added. The
Portal still stores no timezone.
12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no
ETag and never returns 304, to preserve old behaviour.
13. **`systemNameStatus` on v1.** Not added; v1 is unchanged.
14. **Trim/case representation.** Names are trimmed; uniqueness is
case-insensitive; the stored casing is preserved as provided.
15. **Sync cadence.** Resolved in Phase 3: the Kiosk loads its on-disk cache on
boot, then pulls on boot, on a 5-minute timer, and on demand.
16. **Atomic cache file.** Resolved in Phase 3:
`~/.local/radiantwave/schedule_cache.json`, written atomically (temp file +
rename); a corrupt cache is ignored.
17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent
or empty.
18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the
legacy schedule endpoint.
19. **Kiosk local toggle.** `proclamation.schedule_enabled` in `config.yaml`,
default **on**, surfaced as System Settings → Schedule. Not synced. When
off, the engine plays local selections but the cache keeps updating. The
older `proclamation.schedule_active` flag is retained in config but is not
consulted by the Phase 3 engine.
20. **Copy semantics.** Copies are **independent, by value** snapshots: no link
is kept to the source, and editing the source later does not affect targets
(and vice versa). A copy replaces any existing target Schedule in full
(type and blocks) and requires explicit confirmation in the UI when a target
already has one. No "copy to all" shortcut and no scheduled/linked copies
(per the Phase 4 brief). The copied schedule is validated before commit; a
source schedule that fails validation is rejected rather than copied.
21. **Copy and Kiosk code.** Verified against the Phase 3 Kiosk engine/fixture
tests that a copied payload (new Block IDs, same type/times/audio/refs) is
parsed and evaluated identically to any other; **no Kiosk changes were
required**.
22. **Phase 3 migration down is lossy for new-model data.** The `up` migration
(`20260929130000_move_schedule_to_license_key`) is additive and safe. Its
`down` path is **reversible in structure but lossy in data**: the down SQL
drops `Schedule.licenseKeyId`/`type` and `Block.day`, then deletes
`Schedule` rows whose `userId IS NULL`. Any schedule created under the
per-License-Key model (which sets `licenseKeyId` and leaves `userId` NULL)
is therefore **deleted** by a down-migration. Consequences:
- Back up `Schedule`/`Block`/`BlockProclamations` before any down-migration.
- Rolling back past Phase 3 requires restoring that data from backup; it is
**not** a data-preserving rollback.
- Rollback guidance is in
[scheduler-overhaul-release-notes.md](scheduler-overhaul-release-notes.md).
23. **Kiosk UI/E2E on real hardware not run here.** The verification ran the
Portal and the Kiosk schedule logic (engine, client, cache) via the test
harness and real HTTP against a locally running Portal; the SDL Kiosk UI was
not driven interactively because the build environment has no display. See
the manual checklist in the release notes and the Phase 5 report.
24. **Unauthenticated Kiosk endpoints remain a known limitation** (restated from
item 6): the key-only schedule and system-name endpoints authenticate by
License Key alone, mitigated only by the 10/min/IP rate limit. Authentication
is out of scope for this overhaul.
---
## Changelog
- 2026-09-29 — Initial contract created (Phase 0). Documents current state,
target time semantics, schedule data model, validation rules, endpoints
(`/api/v2/licenses/...`, additive license-validation fields), sync behavior,
System Name rules, dynamic audio, backward compatibility, touchpoints,
open items, and fixtures.
- 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and
`Block.day`; the shared `validateSchedule` is authoritative; the key-only
`GET /api/v2/licenses/{licenseKey}/schedule` (ETag/304) ships and the legacy
endpoint is extended additively; the Portal editor is per-system with a
browser-fallback timezone note; the Kiosk adds the conditional client,
atomic cache, schedule engine, and a System Settings On/Off toggle. Open
items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes.
- 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal
action (`?/copy`) backed by a single-transaction service that copies by
value with fresh Block IDs (so target ETags change), preserving `dynamicAudio`
and Proclamation order, scoped to the caller's account, and validated before
commit. Endpoint (e) is finalized; open items 20 and 21 record the copy
semantics and the Kiosk no-change verification.
- 2026-09-29 — Phase 5 (final). End-to-end verification against a locally
running Portal on a fresh database: schedule create/type/copy services and
the Kiosk-facing HTTP endpoints (validation with/without `systemName`,
system-name PUT, key-only schedule GET with ETag/304/404, legacy endpoint
parity, cross-account rejection) all pass; old-client compatibility confirmed;
migrations verified up/down (the down path is lossy, see open item 22). Adds
open items 22–24 (lossy down-migration, unrun UI-on-hardware checks, and the
restated unauthenticated-endpoint limitation) and the release notes.
+55
View File
@@ -0,0 +1,55 @@
# Scheduler Overhaul — Phase 5 Verification Results
Prepared during Phase 5 against a **locally running Portal on a fresh database**.
This records what was actually executed and observed. It is a companion to
`scheduler-overhaul-release-notes.md` (rollout steps) and
`schedule-api-contract.md` (the contract). Where a scenario could not be run
without real Kiosk hardware or an interactive display, the row is marked and the
equivalent automated coverage is cited.
Environment:
- Portal dev server (`npm run dev`) on `http://localhost:5173`, pointed at a
fresh scratch database (`rwportal_p5`) created and migrated for this phase.
- Kiosk schedule logic exercised through the C++ test harness
(`test_schedule_engine`) and the Kiosk API client's JSON parsing.
- No display/Xvfb available, so the SDL Kiosk UI was **not** driven
interactively; see the manual checklist in the release notes.
Legend: **PASS = executed**, **AUTO = verified by automated test** (not
interactively), **MANUAL = requires a human on hardware/display**.
| # | Scenario | Result | Evidence / notes |
| --- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| a | Kiosk shows AM/PM everywhere; System Settings contains Email, License Key, System Name, Schedule toggle, WiFi, Timezone, Date Format | AUTO | `test_date_format` (12h conversion), `test_settings_menu` (exactly the 7 entries incl. System Name + Schedule). UI rendering itself is MANUAL. |
| b | Validate a License Key, name the Kiosk, see the name read-only on the Portal | PASS + MANUAL | HTTP: validation without name → `200 unchanged`; with name → `200 ok`. Portal render is MANUAL (release-notes checklist). |
| c | Two systems in one account: duplicate name in any casing rejected and reverted on the Kiosk | PASS | HTTP: case-insensitive duplicate → validation `200 conflict`; `PUT` duplicate → `409 name_taken`; blank → `400 invalid_name`; unknown → `404 unknown_license`. Kiosk revert covered by `test_license_service`/`test_system_name`. |
| d | Rename a system on the Kiosk; its Schedule stays attached (License Key reference) | AUTO | Schedules are keyed by `licenseKeyId`; naming only touches `systemName`. Portal `schedule-copy`/service tests operate purely on License Key IDs. |
| e | Create a daily Schedule via the Portal picker; Kiosk fetches and applies it | PASS | Opt-in integration spec: daily schedule with 2 blocks created via the service; key-only GET returns it; captured live payload parsed and evaluated by the Kiosk (`SchedulePortalParityTest`). |
| f | Weekly: Block boundaries, adjacent (end 7:00 / start 7:00), midnight-crossing, Sunday→Monday spillover | PASS + AUTO | Integration spec: weekly sun crossing + adjacent mon blocks, Sunday spillover collision rejected. Engine tests: adjacency boundary, midnight self-wrap, Sunday→Monday spillover. |
| g | Gap plays local Proclamations; `dynamicAudio` true → dynamic, false → local | AUTO | Engine tests: gaps return no block; per-block `dynamicAudio` flag; `ScrollerPage::effectiveDynamicAudio` honored via playback wiring (build passes). |
| h | Schedule toggle Off stops schedule effects; On resumes | AUTO | `ScheduleManagerTest.DisabledReturnsNoBlock`; toggle stored in `config.yaml` (`schedule_enabled`, default on). |
| i | Schedules assigned to system A never affect system B | PASS | Integration spec: A daily, B weekly simultaneously; independent payloads. |
| j | Copy a Schedule to another named system; that Kiosk picks it up | PASS | Integration spec: copy A→second system yields daily with 2 blocks; independence confirmed; target ETag differs. Portal `schedule-copy.spec` covers overwrite/multi-target/rollback/new IDs. |
| k | Offline uses cached Schedule; corrupt cache ignored; timezone change re-evaluates | AUTO | `ScheduleCacheTest` (round-trip, corrupt ignored), `ScheduleManagerTest` (offline cache, 304 handled). Timezone is passed as wall-clock minutes (wall-clock means DST-safe). |
| l | Portal shows geoip/fallback timezone with kiosk-timezone note; manual minute entry alongside 5-minute options | AUTO | UI note is browser-fallback (no external dep); `TimeInput` tests cover 5-minute options and typed minutes. Visual render MANUAL. |
## Additional checks
| Check | Result | Notes |
| --------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Old-Kiosk compatibility (validate without `systemName`; legacy schedule endpoint) | PASS | `200 unchanged`; name never cleared; legacy `GET /api/v2/schedule` still returns `email` + `blocks`; modern vs legacy `version` equal. |
| Cross-account isolation | PASS | Legacy schedule with another account's key → `400`; copy rejects non-owned source/targets. |
| Conditional GET | PASS | Matching `If-None-Match` → `304`; stale ETag → `200`; unknown key → `404`. |
| Migration up | PASS | Applied cleanly to a fresh DB (29 migrations); fresh seed succeeded. |
| Migration down/up on realistic data | PASS (with caveat) | Phase 2 and Phase 3 down/up round-tripped structurally on a clone; **the Phase 3 down path is lossy** (deletes per-License-Key schedules). Documented in the contract (Open item 22) and release notes. |
| Portal `svelte-check` / ESLint / tests | PASS | 0 errors/warnings; 394 passed, 4 skipped (opt-in integration). |
| Kiosk build + tests | PASS | Full build; 21 test binaries pass. |
| Formatters | PASS | Portal Prettier clean except the pre-existing `.gitea/workflows/deploy.yaml` (unchanged from before this branch). Kiosk follows `.clang-format` (no binary available to run). |
## Not verified here (requires hardware/display)
The interactive SDL Kiosk UI (rendering, key handling, onboarding walkthrough)
was not driven; all Kiosk behavior above is covered by the engine/client/cache
tests and by parsing live Portal payloads. See the manual checklist in
`scheduler-overhaul-release-notes.md`.
+164
View File
@@ -0,0 +1,164 @@
# Scheduler Overhaul — Release Notes
Status: prepared for rollout. These are **instructions only**; nothing here has
been executed. The contract lives at `docs/schedule-api-contract.md` (kept
byte-identical in both repos).
This document is kept **byte-identical** in both repositories:
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/scheduler-overhaul-release-notes.md`
- `portal` (SvelteKit/Node) — `docs/scheduler-overhaul-release-notes.md`
---
## What changed
### Portal
- **Schedules are assigned to a License Key**, not a user. `Schedule` gained
`licenseKeyId` (unique, nullable) and `type` (`daily` | `weekly` | null);
`Block` gained `day` (null for daily, `mon`..`sun` for weekly) and its
`dynamicAudio` default became `true`.
- **New Kiosk endpoint** `GET /api/v2/licenses/{licenseKey}/schedule` returns the
target payload (`version`, `type`, `daily`, `weekly`, `blocks`) with an `ETag`
derived from `version`, supports conditional GET (`304`), and is `404` for an
unknown key.
- **License validation** (`POST /api/v2/license`) accepts an optional
`systemName` and returns an additive `systemNameStatus`
(`ok` | `unchanged` | `conflict` | `invalid`). Validation always succeeds; a
non-`ok` name status is advisory.
- **New Kiosk endpoint** `PUT /api/v2/licenses/{licenseKey}/system-name`
(`200` / `400 invalid_name` / `404 unknown_license` / `409 name_taken`,
idempotent).
- **System Name** is a human-readable label for a License Key. It is set only
by the Kiosk and shown read-only in the Portal (dashboard and admin lists),
falling back to displaying the License Key. There is no Portal rename path.
Names are unique per account, compared case-insensitively, trimmed, 1–40
characters.
- **Schedule editor** (protected user route `(protected)/(user)/schedule`):
a per-system picker labelled by System Name, a Daily/Weekly type selector with
confirmation when switching a populated schedule (daily→weekly prefills all
seven days; weekly→daily and clearing start empty), per-day weekly editing,
AM/PM `TimeInput` (5-minute suggestions plus manual minute entry), midnight
crossing labelled "ends next day", an on-by-default dynamic-audio toggle, gap
helper text, and an informational note that times run in the Kiosk's own
timezone (browser timezone fallback; no external GeoIP service).
- **Copy to…** copies one system's Schedule to one or more other systems in the
same account, by value, in a single database transaction (all or none). Target
Blocks get fresh IDs so target `version`/ETag changes; `dynamicAudio` and
Proclamation order are preserved. Overwriting a target that already has a
schedule requires explicit confirmation. Copies are independent.
- **OpenAPI** (`docs/openapi.yaml`) documents the new endpoints and payloads.
### Kiosk (`radiantwave`)
- **12-hour AM/PM** time display everywhere the Kiosk shows a time; storage and
parsing remain 24-hour `HH:MM`. The Date Format setting is unchanged.
- **System Settings** menu contains Email Address, License Key, **System Name**,
**Schedule** (on/off), WiFi Setup, Time Zone Setup, and Date Format. The list
is data-driven.
- **System Name** entry (trimmed, 1–40), sent on license validation and via
`PUT /api/v2/licenses/{key}/system-name`. Conflicts/invalid names are reverted
locally with a user-facing message; network failures keep the new name locally
and retry (pending-sync flag) on boot, on validation, and on a timer.
- **Schedule client**: conditional GET (`If-None-Match`) of
`GET /api/v2/licenses/{key}/schedule`, on boot, on network-up, and every
5 minutes. The last payload is cached to
`~/.local/radiantwave/schedule_cache.json` with an **atomic write** (temp file
- rename); a corrupt cache is ignored. Offline uses the cache; no cache and no
network behaves as schedules-off.
- **Schedule engine**: given the payload, the local wall-clock time in the
Kiosk's Timezone setting, and the weekday, picks the active Block. Half-open
`[start, end)` boundaries; midnight-crossing Blocks belong to their start day
(weekly Sunday spills into Monday); invalid Blocks are skipped with a warning
and treated as gaps. Active Block → its Proclamations; `dynamicAudio` true
forces dynamic audio, false uses the locally chosen audio; gaps / no schedule
/ toggle off use local selection exactly as before.
- **Schedule toggle** in System Settings (`proclamation.schedule_enabled`,
default on, Kiosk-local, not synced). Off takes no action but the cache keeps
updating.
---
## Migration notes
- **Portal migrations are additive `up` migrations** and are safe to deploy
while old Kiosks keep running:
- `20260929120000_add_license_key_system_name` — adds
`LicenseKey.systemName` / `systemNameLower` and a unique index on
`(userId, systemNameLower)`.
- `20260929130000_move_schedule_to_license_key` — adds `Schedule.licenseKeyId`
/ `type` and `Block.day`, changes the `dynamicAudio` default to `true`,
makes the legacy `Schedule.userId` optional/non-unique, and backfills each
existing per-user Schedule to that account's **oldest** License Key as a
**daily** schedule. Schedules that cannot be mapped (no owning user/license)
are retained with `licenseKeyId NULL` and reported via a migration `NOTICE`
— they are never silently dropped.
- **Existing (per-user) schedules survive the `up` migration** and are mapped to
a License Key on the same account.
- **⚠ The `down` path is lossy for data created under the new model.** Back up
`Schedule`, `Block`, and `BlockProclamations` before any down-migration.
Rolling back past the Phase 3 migration deletes per-License-Key schedules (see
the contract's Open item 22).
- No Portal schema change affects Kiosks (the Kiosk stores nothing new on the
server beyond the System Name label and its schedule mapping).
---
## Recommended rollout order
Each step is independent; do not skip the verification gates. Do **not** tag,
push, or deploy as part of reading this document — these are instructions.
### Step 1 — Run Portal migrations and deploy the Portal
1. Back up the Portal database (at minimum the `Schedule`, `Block`,
`BlockProclamations`, and `LicenseKey` tables).
2. Apply migrations: `npm run deploy` (which runs `npx prisma migrate deploy`).
3. Confirm `npx prisma migrate status` reports the database is up to date and
review the migration logs for the unmappable-schedule `NOTICE`, if any.
4. Deploy the Portal build (the existing CI/deploy path).
**Rollback (Step 1):** redeploy the previous Portal build. Because the `up`
migrations are additive, the old Portal continues to work against the migrated
schema; no schema rollback is required to redeploy the old Portal. If a schema
rollback is unavoidable, restore from the Step-1 backup (the `down` path is
lossy — see Migration notes).
### Step 2 — Verify old Kiosks still work
1. Against the deployed Portal, confirm an **old Kiosk** (no `systemName`, using
the legacy endpoints) still:
- validates via `POST /api/v2/license` (no `systemName`) and receives `200`;
- reads `GET /api/v2/schedule?email=…&licenseKey=…` and still finds its
`blocks`;
- is unaffected by the new fields, and its stored System Name (if any) is
never cleared.
2. Confirm the Portal dashboard/admin show System Names read-only.
**Rollback (Step 2):** none needed — this step is read-only verification. If old
Kiosks misbehave, roll back the Portal (Step 1 rollback) before proceeding.
### Step 3 — Release the Kiosk build
1. Build and validate the Kiosk through the normal path: tag → CI → the
auto-update channel. (Tagging triggers production CI; do it only when ready.)
2. Confirm the released build reports the expected version and updates in place.
3. Spot-check a device after update: 12-hour times, System Settings contents,
schedule on/off, and that an assigned Schedule is fetched and applied.
**Rollback (Step 3):** use the existing auto-update rollback (re-publish or
pin the previous known-good Kiosk version). The Kiosk tolerates both old and new
Portal payloads, so a Portal rollback alone does not require a Kiosk rollback,
and vice versa.
---
## Known limitations
- Kiosk API endpoints are **unauthenticated** (key-only), mitigated by a
10 requests/min/IP rate limit. Authentication is out of scope for this
overhaul.
- The Phase 3 migration's `down` path is lossy for new-model schedules
(see Migration notes and contract Open item 22).
- The Portal does not offer a rename path for System Names by design.
@@ -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 $$;
+70 -1
View File
@@ -28,6 +28,7 @@ model User {
profiles Profile[] profiles Profile[]
licenseKeys LicenseKey[] licenseKeys LicenseKey[]
userProclamations UserProclamations[] userProclamations UserProclamations[]
schedules Schedule[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -130,6 +131,7 @@ model Proclamation {
groupId Int groupId Int
group ProclamationGroup @relation(fields: [groupId], references: [id]) group ProclamationGroup @relation(fields: [groupId], references: [id])
userProclamations UserProclamations[] userProclamations UserProclamations[]
blockProclamations BlockProclamations[]
// Legacy subscription-level FK. Retained in the database but unmanaged; the // 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. // FK constraint is dropped and the value is left NULL. Hidden from the client.
subscriptionLevelId Int? @ignore subscriptionLevelId Int? @ignore
@@ -186,7 +188,7 @@ model ProclamationCategoryLegacy {
// `@@ignore` so Prisma remains aware of the table but it is excluded from the // `@@ignore` so Prisma remains aware of the table but it is excluded from the
// generated client and Prisma's own migrations. // generated client and Prisma's own migrations.
model ProclamationCategorySubscriptionLevelLegacy { model ProclamationCategorySubscriptionLevelLegacy {
categoryId Int categoryId Int
subscriptionLevelId Int subscriptionLevelId Int
@@id([categoryId, subscriptionLevelId]) @@id([categoryId, subscriptionLevelId])
@@ -203,10 +205,18 @@ model LicenseKey {
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String? 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 // Legacy subscription level. Retained in the database but unmanaged; hidden
// from the client and left NULL. // from the client and left NULL.
level String? @ignore level String? @ignore
@@unique([userId, systemNameLower])
@@index([userId]) @@index([userId])
@@index([email]) @@index([email])
} }
@@ -253,3 +263,62 @@ model AuditLog {
@@index([page]) @@index([page])
@@map("AuditLog") @@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])
}
+163
View File
@@ -0,0 +1,163 @@
import 'dotenv/config';
// Exercises the Kiosk-facing HTTP API exactly as the Kiosk client does.
// Scenarios: b (validate + name), c (duplicate name conflict), k (ETag/304),
// old-client backward compatibility.
const BASE = process.env.P5_BASE ?? 'http://localhost:5173';
const KA1 = 'ALCE-AAAA-AAAA-0001';
const KA2 = 'ALCE-AAAA-AAAA-0002';
const KB1 = 'BOBB-BBBB-BBBB-0001';
const EMAIL = 'p5-alice@example.com';
const results: { id: string; pass: boolean; note: string }[] = [];
function rec(id: string, pass: boolean, note: string) {
results.push({ id, pass, note });
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${note}`);
}
async function post(path: string, body: unknown) {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.json().catch(() => null), headers: res.headers };
}
async function put(path: string, body: unknown) {
const res = await fetch(BASE + path, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function get(path: string, headers: Record<string, string> = {}) {
const res = await fetch(BASE + path, { headers });
return { status: res.status, body: await res.json().catch(() => null), headers: res.headers };
}
async function main() {
// (b) validate without a name first (old-client behavior), then set a name.
const validateNoName = await post('/api/v2/license', {
license_key: KA1,
email_address: EMAIL
});
rec(
'b1',
validateNoName.status === 200 && validateNoName.body?.systemNameStatus === 'unchanged',
`validation without systemName -> ${validateNoName.status}, status=${validateNoName.body?.systemNameStatus}`
);
const setName = await post('/api/v2/license', {
license_key: KA1,
email_address: EMAIL,
systemName: 'Living Room'
});
rec(
'b2',
setName.status === 200 && setName.body?.systemNameStatus === 'ok',
`name a Kiosk -> ${setName.status}, status=${setName.body?.systemNameStatus}`
);
// (c) duplicate name, different casing, same account -> conflict + not cleared.
const dup = await post('/api/v2/license', {
license_key: KA2,
email_address: EMAIL,
systemName: 'living room'
});
rec(
'c1',
dup.status === 200 && dup.body?.systemNameStatus === 'conflict',
`case-insensitive duplicate -> status=${dup.body?.systemNameStatus} (validation still ${dup.status})`
);
// Confirm the original name survived and KA2 has no name.
const after = await get('/api/v2/licenses/' + KA1 + '/schedule');
rec('c2', after.status === 200, `KA1 schedule GET after conflict -> ${after.status}`);
// (c) PUT system-name endpoint: 409 for a taken name.
const putDup = await put('/api/v2/licenses/' + KA2 + '/system-name', {
systemName: 'LIVING ROOM'
});
rec(
'c3',
putDup.status === 409 && putDup.body?.message === 'name_taken',
`PUT duplicate -> ${putDup.status} ${putDup.body?.message}`
);
const putOk = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: 'Kitchen' });
rec('c4', putOk.status === 200, `PUT unique name -> ${putOk.status}`);
const putInvalid = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: ' ' });
rec(
'c5',
putInvalid.status === 400 && putInvalid.body?.message === 'invalid_name',
`PUT blank -> ${putInvalid.status} ${putInvalid.body?.message}`
);
const putUnknown = await put('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/system-name', {
systemName: 'X'
});
rec(
'c6',
putUnknown.status === 404 && putUnknown.body?.message === 'unknown_license',
`PUT unknown license -> ${putUnknown.status} ${putUnknown.body?.message}`
);
// (k) schedule GET parity between the legacy endpoint and the new key-only endpoint.
const modern = await get('/api/v2/licenses/' + KA1 + '/schedule');
const legacy = await get(
'/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KA1
);
rec(
'k1',
modern.status === 200 && legacy.status === 200 && modern.body?.version === legacy.body?.version,
`modern vs legacy version equal=${modern.body?.version === legacy.body?.version}`
);
// (k) conditional GET: matching If-None-Match -> 304.
const etag = modern.headers.get('etag') ?? '';
const notModified = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': etag });
rec('k2', notModified.status === 304, `conditional GET with ETag -> ${notModified.status}`);
const stale = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': '"stale"' });
rec('k3', stale.status === 200, `conditional GET with stale ETag -> ${stale.status}`);
// Unknown license -> 404.
const missing = await get('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/schedule');
rec('k4', missing.status === 404, `unknown license schedule GET -> ${missing.status}`);
// Old-client validation (no systemName field at all) never clears the name.
const validateAgain = await post('/api/v2/license', { license_key: KA1, email_address: EMAIL });
const checkName = await put('/api/v2/licenses/' + KA1 + '/system-name', {
systemName: 'Living Room'
});
rec(
'old1',
validateAgain.status === 200 &&
validateAgain.body?.systemNameStatus === 'unchanged' &&
checkName.status === 200,
`old-client validate stays unchanged; name still present`
);
// Old-client list endpoint shape: blocks array still present.
rec(
'old2',
legacy.status === 200 && Array.isArray(legacy.body?.blocks),
`legacy endpoint still returns email+blocks (${legacy.body?.blocks?.length} blocks)`
);
// Cross-account: Bob's key with Alice's email must fail.
const cross = await get(
'/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KB1
);
rec('auth1', cross.status === 400, `cross-account legacy schedule -> ${cross.status}`);
console.log('\nP5_HTTP_RESULTS ' + JSON.stringify(results));
}
main().catch((e) => {
console.error('RUNNER ERROR', e);
process.exit(1);
});
+45
View File
@@ -0,0 +1,45 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
// A handful of real Proclamations so schedule Blocks have valid references.
async function main() {
const category = await prisma.proclamationCategory.findFirst({ orderBy: { id: 'asc' } });
const group = await prisma.proclamationGroup.findFirst({ orderBy: { id: 'asc' } });
const freq = await prisma.solfeggioFrequency.findFirst({ orderBy: { id: 'asc' } });
if (!category || !group || !freq) throw new Error('seed missing catalog');
const names = ['P5 Dawn', 'P5 Dusk', 'P5 Noon', 'P5 Night'];
const ids: string[] = [];
for (const name of names) {
const existing = await prisma.proclamation.findUnique({ where: { name } });
if (existing) {
ids.push(existing.id);
continue;
}
const created = await prisma.proclamation.create({
data: {
name,
content: `<p>${name} content</p>`,
categoryId: category.id,
groupId: group.id,
solfeggioFrequencyId: freq.id
}
});
ids.push(created.id);
}
console.log(JSON.stringify(ids));
await prisma.$disconnect();
await pool.end();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
// Two accounts, each with two License Keys, for scenario coverage:
// - acct A: keys KA1, KA2 (same account -> name uniqueness + copy)
// - acct B: key KB1 (cross-account isolation)
// Names are left NULL; the Kiosk sets them via the API.
const accounts = [
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0001' },
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0002' },
{ email: 'p5-bob@example.com', key: 'BOBB-BBBB-BBBB-0001' }
];
async function ensureUser(email) {
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({
data: { id: crypto.randomUUID(), name: email.split('@')[0], email, emailVerified: true }
});
}
return user;
}
async function main() {
for (const { email, key } of accounts) {
const user = await ensureUser(email);
const existing = await prisma.licenseKey.findUnique({ where: { key } });
if (!existing) {
await prisma.licenseKey.create({ data: { key, active: true, userId: user.id } });
}
}
console.log('P5 test data ready:');
for (const { email, key } of accounts) console.log(` ${email} -> ${key}`);
await prisma.$disconnect();
await pool.end();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
import 'dotenv/config';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/lib/generated/prisma/client';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
const schedules = await prisma.schedule.findMany({ include: { blocks: true } });
const keys = await prisma.licenseKey.findMany({ select: { key: true, systemName: true } });
console.log(
JSON.stringify({
scheduleCount: schedules.length,
blockCount: schedules.reduce((n, s) => n + s.blocks.length, 0),
types: schedules.map((s) => s.type),
names: keys
.filter((k) => k.systemName)
.map((k) => k.systemName)
.sort()
})
);
await prisma.$disconnect();
await pool.end();
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import {
fiveMinuteOptions,
formatTime,
hmToMinutes,
parseTimeInput,
toHm
} from '$lib/schedule-utils';
// A reusable 12-hour clock input. Displays and accepts AM/PM times, offers
// the 5-minute options as suggestions, and allows typing any minute 00-59.
// Its value model is a 24-hour "HH:MM" string.
let {
value = '00:00',
label,
onchange,
onvalidity,
required = false,
disabled = false
}: {
value?: string;
label?: string;
onchange?: (value: string) => void;
onvalidity?: (valid: boolean) => void;
required?: boolean;
disabled?: boolean;
} = $props();
const inputId = $props.id();
const options = fiveMinuteOptions();
// While the user is typing we show their raw text (`editing`); otherwise we
// show the canonical 12-hour label derived from the 24-hour value.
let editing = $state<string | null>(null);
const valueMinutes = $derived(hmToMinutes(value) ?? 0);
const display = $derived(editing ?? formatTime(valueMinutes));
const error = $derived.by(() => {
if (editing === null) return '';
const result = parseTimeInput(editing);
return result.ok ? '' : result.error;
});
function handleInput(event: Event) {
const raw = (event.currentTarget as HTMLInputElement).value;
editing = raw;
const result = parseTimeInput(raw);
if (result.ok) {
onvalidity?.(true);
onchange?.(toHm(result.minutes));
} else {
onvalidity?.(false);
}
}
function handleBlur() {
if (editing === null) return;
const result = parseTimeInput(editing);
if (result.ok) {
// Drop back to the canonical label derived from the value.
editing = null;
onvalidity?.(true);
}
}
function handleFocus(event: FocusEvent) {
editing = (event.currentTarget as HTMLInputElement).value;
}
</script>
<div class="flex flex-col gap-1">
{#if label}
<label for={inputId} class="text-sm font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
{/if}
<input
id={inputId}
type="text"
inputmode="text"
autocomplete="off"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
list={`${inputId}-options`}
{required}
{disabled}
aria-label={label}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : undefined}
value={display}
oninput={handleInput}
onfocus={handleFocus}
onblur={handleBlur}
/>
<datalist id={`${inputId}-options`}>
{#each options as minutes (minutes)}
<option value={formatTime(minutes)}></option>
{/each}
</datalist>
{#if error}
<p id={`${inputId}-error`} role="alert" class="text-sm text-red-600 dark:text-red-400">
{error}
</p>
{/if}
</div>
@@ -0,0 +1,66 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import TimeInput from './TimeInput.svelte';
function mount(props: Record<string, unknown> = {}) {
return render(TimeInput, { value: '07:05', label: 'Time', ...props });
}
async function type(text: string) {
const input = (await page.getByRole('combobox', { name: 'Time' }).element()) as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('TimeInput', () => {
it('displays the 24-hour value as a 12-hour label', async () => {
mount({ value: '21:05' });
await expect.element(page.getByRole('combobox', { name: 'Time' })).toHaveValue('9:05 PM');
});
it('calls onchange with a 24-hour value for tolerant input', async () => {
const onchange = vi.fn();
mount({ onchange });
await type('7:05 pm');
expect(onchange).toHaveBeenCalledWith('19:05');
});
it('accepts typed minutes that are not 5-minute steps', async () => {
const onchange = vi.fn();
mount({ value: '00:00', onchange });
await type('7:07');
expect(onchange).toHaveBeenCalledWith('07:07');
});
it('accepts an hour with no minutes', async () => {
const onchange = vi.fn();
mount({ onchange });
await type('7pm');
expect(onchange).toHaveBeenCalledWith('19:00');
});
it('shows clear feedback and reports invalid input', async () => {
const onvalidity = vi.fn();
mount({ onvalidity });
await type('13:00 PM');
await expect.element(page.getByRole('alert')).toBeInTheDocument();
expect(onvalidity).toHaveBeenCalledWith(false);
});
it('offers the 5-minute clock options', async () => {
mount();
const options = document.querySelectorAll('datalist option');
expect(options).toHaveLength((24 * 60) / 5);
});
});
@@ -0,0 +1,369 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import TimeInput from '$lib/components/TimeInput.svelte';
import {
formatTime,
hmToMinutes,
isOvernight,
toHm,
validateBlockAgainstSchedule,
type BlockSibling
} from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
day = null,
licenseKeyId = '',
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = true,
scheduleType = 'daily' as 'daily' | 'weekly' | null,
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
day?: string | null;
licenseKeyId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
scheduleType?: 'daily' | 'weekly' | null;
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
// Seeded from the props in the effect below whenever the modal opens.
let startMinutes = $state(9 * 60);
let endMinutes = $state(10 * 60);
let startValid = $state(true);
let endValid = $state(true);
let selectedIds = $state<string[]>([]);
let dynamicAudio = $state(true);
let error = $state('');
function setStart(hm: string) {
const minutes = hmToMinutes(hm);
if (minutes !== null) startMinutes = minutes;
}
function setEnd(hm: string) {
const minutes = hmToMinutes(hm);
if (minutes !== null) endMinutes = minutes;
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
startMinutes = initialStartMinutes;
endMinutes = initialEndMinutes;
startValid = true;
endValid = true;
selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = '';
step = 'details';
});
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
if (!startValid || !endValid) return 'Enter a valid start and end time.';
if (scheduleType === null) return 'Set a schedule type first.';
const result = validateBlockAgainstSchedule(
scheduleType,
{ day, startMinutes, endMinutes, proclamationIds: selectedIds },
siblings
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="licenseKeyId" value={licenseKeyId} />
{#if day}
<input type="hidden" name="day" value={day} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<TimeInput
label="Start time"
value={toHm(startMinutes)}
onchange={setStart}
onvalidity={(valid) => (startValid = valid)}
/>
<TimeInput
label="End time"
value={toHm(endMinutes)}
onchange={setEnd}
onvalidity={(valid) => (endValid = valid)}
/>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
>
<input
type="checkbox"
checked={dynamicAudio}
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
Enable Dynamic Audio
</label>
<span class="group relative inline-flex">
<button
type="button"
aria-label="What is Dynamic Audio?"
aria-describedby="dynamic-audio-help"
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
>
?
</button>
<span
id="dynamic-audio-help"
role="tooltip"
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
>
Dynamic audio will be enabled for this block, while this block is playing.
This will override any audio selections you have made on your device while
this block is active.
</span>
</span>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (ends next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div class="flex items-center gap-3">
{#if step === 'details' && mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
</div>
<div class="flex items-center gap-3">
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Accept
</button>
{:else}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,198 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
async function typeTime(name: string, text: string) {
const input = (await page.getByRole('combobox', { name }).element()) as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('BlockModal', () => {
it('uses a 12-hour TimeInput for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'End time' })).toBeInTheDocument();
});
it('pre-fills the fields from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('7:30 AM');
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('9:15 AM');
});
it('shows a 12-hour clock with AM/PM', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('9:00 PM');
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('12:00 AM');
});
it('recomputes the time from typed input', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
await typeTime('End time', '10:00 PM');
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
});
it('shows validation feedback for unparseable input', async () => {
mount();
await typeTime('Start time', '25:00');
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (ends next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('shows Cancel with Accept in the browse step, and Accept returns to the details', async () => {
mount();
const browse = page.getByRole('button', { name: 'Browse proclamations' });
await expect.element(browse).toBeInTheDocument();
(browse.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const accept = page.getByRole('button', { name: 'Accept' });
await expect.element(accept).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Block' }))
.not.toBeInTheDocument();
(accept.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
});
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
await expect
.element(page.getByText(/Dynamic audio will be enabled for this block/))
.toBeInTheDocument();
});
it('defaults dynamic audio on for new blocks', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
mount({ initialDynamicAudio: true });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('leaves dynamic audio off when editing a block that has it disabled', async () => {
mount({ initialDynamicAudio: false });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
});
it('toggles the dynamic audio flag in the submitted payload', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('false');
});
});
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});
@@ -0,0 +1,287 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
day?: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
type OpenBlock = {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
let {
days,
blocks = [],
onCreate,
onEdit
}: {
days: { key: string; label: string; short?: string }[];
blocks?: ScheduleBlockView[];
onCreate?: (day: string, startMinutes: number, endMinutes: number) => void;
onEdit?: (block: OpenBlock) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
// Which grid the pointer is currently over, so the live preview shows up in
// the right day column (Google-Calendar style).
let drag = $state<{
day: string;
anchor: number;
current: number;
pointerId: number;
} | null>(null);
function dayLabel(key: string): string {
return days.find((d) => d.key === key)?.label ?? key;
}
function minutesFromEvent(event: PointerEvent, column: HTMLElement): number {
const rect = column.getBoundingClientRect();
// Guard against an unmeasured/zero-height column (e.g. before layout) so
// a tap still yields a valid, finite start minute instead of NaN.
if (!rect.height) return 0;
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
// Pointer capture happens on the column element, so moves/ups land there even
// when the pointer leaves the small cell.
function startDrag(event: PointerEvent, day: string) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const column = event.currentTarget as HTMLElement;
const minutes = minutesFromEvent(event, column);
drag = { day, anchor: minutes, current: minutes, pointerId: event.pointerId };
column.setPointerCapture(event.pointerId);
event.preventDefault();
}
function moveDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const column = event.currentTarget as HTMLElement;
drag = { ...drag, current: minutesFromEvent(event, column) };
}
function endDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const { day, anchor, current } = drag;
const column = event.currentTarget as HTMLElement;
column.releasePointerCapture?.(event.pointerId);
drag = null;
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(day, start, end);
return;
}
// A plain click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.min(MINUTES_PER_DAY, clickStart + defaultDuration);
onCreate?.(day, Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
day: drag.day,
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
function blocksForDay(day: string): ScheduleBlockView[] {
// Daily schedules use a single "daily" column while their Blocks carry
// day=null; weekly columns match the `mon`..`sun` keys directly.
return blocks.filter((b) => (day === 'daily' ? b.day == null : b.day === day));
}
// The column before each day, wrapping across the schedule (mon's previous
// day is sun; the single daily column is its own previous day). Used to spill
// an overnight block's post-midnight tail into the next day's column.
const previousDayKey: Record<string, string> = $derived.by(() => {
const map: Record<string, string> = {};
days.forEach((day, index) => {
map[day.key] = days[(index - 1 + days.length) % days.length].key;
});
return map;
});
type DayView = {
block: ScheduleBlockView;
// A same-day block, the pre-midnight start of an overnight block, or the
// post-midnight tail of the previous day's overnight block.
part: 'whole' | 'head' | 'tail';
top: number;
height: number;
};
function toView(
block: ScheduleBlockView,
part: DayView['part'],
[start, end]: [number, number]
): DayView {
return {
block,
part,
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
}
// A column shows its own blocks plus the tails of the previous day's
// overnight blocks. An overnight block contributes only its pre-midnight
// head to its start day; its tail is drawn at the top of the next day
// (weekly sun 7PM→1AM spills into mon). For daily, the previous day is the
// same single column, so both parts render together as before.
function viewsForDay(day: string): DayView[] {
const views: DayView[] = [];
for (const block of blocksForDay(day)) {
const head = blockSegments(block)[0];
if (!head || head[1] <= head[0]) continue;
views.push(toView(block, isOvernight(block) ? 'head' : 'whole', head));
}
const previous = previousDayKey[day];
if (previous) {
for (const block of blocksForDay(previous)) {
if (!isOvernight(block)) continue;
const tail = blockSegments(block)[1];
if (!tail || tail[1] <= tail[0]) continue;
views.push(toView(block, 'tail', tail));
}
}
return views;
}
</script>
<!--
Schedule as a Google-Calendar-style grid: day columns side by side with a
shared time gutter. Scrolls horizontally on narrow screens (the gutter stays
pinned); tap/click or drag an empty slot to create a block at that time, tap a
block to edit it.
-->
<div
class="isolate overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex overflow-x-auto" style="overscroll-behavior-x: contain;">
<!-- Time gutter (pinned while the day columns scroll horizontally). -->
<div
class="sticky left-0 z-30 w-20 shrink-0 border-r border-slate-200 bg-white sm:w-24 dark:border-slate-800 dark:bg-slate-900"
>
<!-- Spacer aligning with the day header row. -->
<div class="h-12 border-b border-slate-200 dark:border-slate-800"></div>
<div class="relative h-[720px]" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-2 -translate-y-1/2 text-xs whitespace-nowrap text-slate-400 tabular-nums dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{formatTime(hour * 60)}
</div>
{/each}
</div>
</div>
<!-- Day columns share the available width equally so a weekly view fits
without scrolling on desktop. Each keeps a minimum width, so when the
seven days cannot fit (phones) the row scrolls horizontally. -->
{#each days as day (day.key)}
<div
class="min-w-24 flex-1 basis-0 border-r border-slate-200 last:border-r-0 dark:border-slate-800"
>
<div
class="flex h-12 items-center justify-center border-b border-slate-200 px-1 text-xs font-semibold text-slate-700 sm:text-sm dark:border-slate-800 dark:text-slate-200"
>
{#if day.short}
<span class="sm:hidden">{day.short}</span>
<span class="hidden sm:inline">{day.label}</span>
{:else}
{day.label}
{/if}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-[720px] cursor-pointer select-none"
style="touch-action: pan-x pan-y;"
aria-label={`Schedule grid ${dayLabel(day.key)}`}
onpointerdown={(e) => startDrag(e, day.key)}
onpointermove={moveDrag}
onpointerup={endDrag}
onpointercancel={(e) => {
if (drag && e.pointerId === drag.pointerId) drag = null;
}}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview && preview.day === day.key}
<div
class="pointer-events-none absolute inset-x-1 z-10 rounded-md bg-indigo-200/70 dark:bg-indigo-700/50"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.({ ...view.block, day: view.block.day ?? null });
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-0.5 z-20 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-1.5 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/70 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${view.top}%;height:${view.height}%`}
>
{#if view.part === 'tail'}
<span class="block truncate font-semibold tabular-nums">
{formatTime(0)}–{formatTime(view.block.endMinutes)}
</span>
<span class="block truncate text-[11px] opacity-80">↳ continues</span>
{:else}
<span class="block truncate font-semibold tabular-nums">
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
</span>
{#if view.part === 'head'}
<span class="block truncate text-[11px] opacity-80">ends next day</span>
{/if}
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
@@ -0,0 +1,172 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import WeekSchedule from './WeekSchedule.svelte';
const WEEK_DAYS = [
{ key: 'mon', label: 'Monday' },
{ key: 'tue', label: 'Tuesday' },
{ key: 'wed', label: 'Wednesday' },
{ key: 'thu', label: 'Thursday' },
{ key: 'fri', label: 'Friday' },
{ key: 'sat', label: 'Saturday' },
{ key: 'sun', label: 'Sunday' }
];
const block = (overrides: Record<string, unknown> = {}) => ({
id: 'b1',
day: 'mon',
startMinutes: 6 * 60,
endMinutes: 8 * 60,
dynamicAudio: true,
proclamations: [{ id: 'p1', name: 'Peace' }],
...overrides
});
function mount(props: Record<string, unknown> = {}) {
return render(WeekSchedule, { days: WEEK_DAYS, blocks: [], ...props });
}
// Click at a fraction of the way down a day column.
async function clickColumn(label: string, fraction: number) {
const column = document.querySelector(
`[aria-label="Schedule grid ${label}"]`
) as HTMLElement | null;
if (!column) throw new Error(`no column ${label}`);
const rect = column.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height * fraction;
column.dispatchEvent(
new PointerEvent('pointerdown', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
);
column.dispatchEvent(
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
);
}
describe('WeekSchedule', () => {
it('renders all seven day columns side by side', async () => {
mount();
for (const day of WEEK_DAYS) {
await expect.element(page.getByText(day.label)).toBeInTheDocument();
}
});
it('renders a single column for a daily schedule', async () => {
mount({ days: [{ key: 'daily', label: 'Every day' }] });
await expect.element(page.getByText('Every day')).toBeInTheDocument();
expect(document.querySelectorAll('[aria-label^="Schedule grid"]')).toHaveLength(1);
});
it('is horizontally scrollable with a pinned time gutter (mobile-friendly)', async () => {
mount();
// The day columns live in a horizontally scrollable row.
const scroller = document.querySelector('.overflow-x-auto') as HTMLElement | null;
expect(scroller).not.toBeNull();
// The time gutter is sticky so it stays visible while days scroll.
const gutter = scroller?.querySelector('[class*="sticky"][class*="left-0"]');
expect(gutter).not.toBeNull();
// Each day column has a minimum basis width so it stays legible on a phone.
const columns = scroller?.querySelectorAll('[aria-label^="Schedule grid"]') ?? [];
expect(columns.length).toBe(7);
});
it('shows short day labels on small screens', async () => {
mount({
days: [
{ key: 'mon', label: 'Monday', short: 'Mon' },
{ key: 'tue', label: 'Tuesday', short: 'Tue' }
]
});
// Both the short (small-screen) and long (large-screen) labels exist in
// the DOM; CSS decides which is visible at a given width.
const text = document.body.textContent ?? '';
expect(text).toContain('Mon');
expect(text).toContain('Monday');
});
it('shows blocks as events', async () => {
mount({ blocks: [block()] });
await expect.element(page.getByText('Peace')).toBeInTheDocument();
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
});
it('renders daily blocks (day null) in the single daily column', async () => {
mount({
days: [{ key: 'daily', label: 'Every day' }],
blocks: [block({ day: null })]
});
await expect.element(page.getByText('Peace')).toBeInTheDocument();
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
});
function column(label: string): HTMLElement {
return document.querySelector(`[aria-label="Schedule grid ${label}"]`) as HTMLElement;
}
it('spills an overnight weekly block into the next day column', async () => {
mount({ blocks: [block({ day: 'sun', startMinutes: 19 * 60, endMinutes: 60 })] });
// The start day keeps only the pre-midnight head.
const sunday = column('Sunday');
expect(sunday.textContent).toContain('7:00 PM–1:00 AM');
expect(sunday.textContent).toContain('ends next day');
expect(sunday.textContent).not.toContain('continues');
// The post-midnight tail is drawn on the following day.
const monday = column('Monday');
expect(monday.textContent).toContain('12:00 AM–1:00 AM');
expect(monday.textContent).toContain('↳ continues');
});
it('spills a Saturday overnight block into Sunday', async () => {
mount({ blocks: [block({ day: 'sat', startMinutes: 23 * 60, endMinutes: 30 })] });
const sunday = column('Sunday');
expect(sunday.textContent).toContain('12:00 AM–12:30 AM');
expect(sunday.textContent).toContain('↳ continues');
});
it('keeps both parts of a daily overnight block in its single column', async () => {
mount({
days: [{ key: 'daily', label: 'Every day' }],
blocks: [block({ day: null, startMinutes: 21 * 60, endMinutes: 9 * 60 })]
});
const daily = column('Every day');
expect(daily.textContent).toContain('9:00 PM–9:00 AM');
expect(daily.textContent).toContain('ends next day');
expect(daily.textContent).toContain('12:00 AM–9:00 AM');
expect(daily.textContent).toContain('↳ continues');
});
it('opens create on the clicked day', async () => {
const onCreate = vi.fn();
mount({ onCreate });
await clickColumn('Monday', 0.25);
expect(onCreate).toHaveBeenCalledTimes(1);
const [day, start, end] = onCreate.mock.calls[0] as [string, number, number];
expect(day).toBe('mon');
// The handler always yields a sensible minute range (a default-length
// block when the click distance is below the snap threshold).
expect(Number.isFinite(start)).toBe(true);
expect(Number.isFinite(end)).toBe(true);
expect(end - start).toBeGreaterThan(0);
});
it('opens edit when a block is clicked', async () => {
const onEdit = vi.fn();
mount({ blocks: [block()], onEdit });
await page.getByLabelText('Block 6:00 AM to 8:00 AM').click();
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'b1', day: 'mon' });
});
});
+15
View File
@@ -97,3 +97,18 @@ export type ColorPalette = Prisma.ColorPaletteModel
* *
*/ */
export type AuditLog = Prisma.AuditLogModel 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
+15
View File
@@ -121,3 +121,18 @@ export type ColorPalette = Prisma.ColorPaletteModel
* *
*/ */
export type AuditLog = Prisma.AuditLogModel 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
File diff suppressed because one or more lines are too long
@@ -399,7 +399,10 @@ export const ModelName = {
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette', ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog' AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -415,7 +418,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog" modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog" | "schedule" | "block" | "blockProclamations"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1603,6 +1606,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
Schedule: {
payload: Prisma.$SchedulePayload<ExtArgs>
fields: Prisma.ScheduleFieldRefs
operations: {
findUnique: {
args: Prisma.ScheduleFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findUniqueOrThrow: {
args: Prisma.ScheduleFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findFirst: {
args: Prisma.ScheduleFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findFirstOrThrow: {
args: Prisma.ScheduleFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findMany: {
args: Prisma.ScheduleFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
create: {
args: Prisma.ScheduleCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
createMany: {
args: Prisma.ScheduleCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ScheduleCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
delete: {
args: Prisma.ScheduleDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
update: {
args: Prisma.ScheduleUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
deleteMany: {
args: Prisma.ScheduleDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ScheduleUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ScheduleUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
upsert: {
args: Prisma.ScheduleUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
aggregate: {
args: Prisma.ScheduleAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSchedule>
}
groupBy: {
args: Prisma.ScheduleGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleGroupByOutputType>[]
}
count: {
args: Prisma.ScheduleCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleCountAggregateOutputType> | number
}
}
}
Block: {
payload: Prisma.$BlockPayload<ExtArgs>
fields: Prisma.BlockFieldRefs
operations: {
findUnique: {
args: Prisma.BlockFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findFirst: {
args: Prisma.BlockFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findMany: {
args: Prisma.BlockFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
create: {
args: Prisma.BlockCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
createMany: {
args: Prisma.BlockCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
delete: {
args: Prisma.BlockDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
update: {
args: Prisma.BlockUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
deleteMany: {
args: Prisma.BlockDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
upsert: {
args: Prisma.BlockUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
aggregate: {
args: Prisma.BlockAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlock>
}
groupBy: {
args: Prisma.BlockGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockGroupByOutputType>[]
}
count: {
args: Prisma.BlockCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockCountAggregateOutputType> | number
}
}
}
BlockProclamations: {
payload: Prisma.$BlockProclamationsPayload<ExtArgs>
fields: Prisma.BlockProclamationsFieldRefs
operations: {
findUnique: {
args: Prisma.BlockProclamationsFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockProclamationsFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findFirst: {
args: Prisma.BlockProclamationsFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockProclamationsFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findMany: {
args: Prisma.BlockProclamationsFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
create: {
args: Prisma.BlockProclamationsCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
createMany: {
args: Prisma.BlockProclamationsCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockProclamationsCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
delete: {
args: Prisma.BlockProclamationsDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
update: {
args: Prisma.BlockProclamationsUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
deleteMany: {
args: Prisma.BlockProclamationsDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockProclamationsUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockProclamationsUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
upsert: {
args: Prisma.BlockProclamationsUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
aggregate: {
args: Prisma.BlockProclamationsAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlockProclamations>
}
groupBy: {
args: Prisma.BlockProclamationsGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsGroupByOutputType>[]
}
count: {
args: Prisma.BlockProclamationsCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsCountAggregateOutputType> | number
}
}
}
} }
} & { } & {
other: { other: {
@@ -1795,7 +2020,9 @@ export const LicenseKeyScalarFieldEnum = {
createdAt: 'createdAt', createdAt: 'createdAt',
lastValidatedAt: 'lastValidatedAt', lastValidatedAt: 'lastValidatedAt',
userId: 'userId', userId: 'userId',
email: 'email' email: 'email',
systemName: 'systemName',
systemNameLower: 'systemNameLower'
} as const } as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum] export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
@@ -1840,6 +2067,43 @@ export const AuditLogScalarFieldEnum = {
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum] 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 = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -2096,6 +2360,9 @@ export type GlobalOmitConfig = {
apiKey?: Prisma.ApiKeyOmit apiKey?: Prisma.ApiKeyOmit
colorPalette?: Prisma.ColorPaletteOmit colorPalette?: Prisma.ColorPaletteOmit
auditLog?: Prisma.AuditLogOmit auditLog?: Prisma.AuditLogOmit
schedule?: Prisma.ScheduleOmit
block?: Prisma.BlockOmit
blockProclamations?: Prisma.BlockProclamationsOmit
} }
/* Types for Logging */ /* Types for Logging */
@@ -66,7 +66,10 @@ export const ModelName = {
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette', ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog' AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -238,7 +241,9 @@ export const LicenseKeyScalarFieldEnum = {
createdAt: 'createdAt', createdAt: 'createdAt',
lastValidatedAt: 'lastValidatedAt', lastValidatedAt: 'lastValidatedAt',
userId: 'userId', userId: 'userId',
email: 'email' email: 'email',
systemName: 'systemName',
systemNameLower: 'systemNameLower'
} as const } as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum] export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
@@ -283,6 +288,43 @@ export const AuditLogScalarFieldEnum = {
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum] 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 = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
+3
View File
@@ -24,4 +24,7 @@ export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts' export type * from './models/ApiKey.ts'
export type * from './models/ColorPalette.ts' export type * from './models/ColorPalette.ts'
export type * from './models/AuditLog.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' export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+197 -2
View File
@@ -32,6 +32,8 @@ export type LicenseKeyMinAggregateOutputType = {
lastValidatedAt: Date | null lastValidatedAt: Date | null
userId: string | null userId: string | null
email: string | null email: string | null
systemName: string | null
systemNameLower: string | null
} }
export type LicenseKeyMaxAggregateOutputType = { export type LicenseKeyMaxAggregateOutputType = {
@@ -42,6 +44,8 @@ export type LicenseKeyMaxAggregateOutputType = {
lastValidatedAt: Date | null lastValidatedAt: Date | null
userId: string | null userId: string | null
email: string | null email: string | null
systemName: string | null
systemNameLower: string | null
} }
export type LicenseKeyCountAggregateOutputType = { export type LicenseKeyCountAggregateOutputType = {
@@ -52,6 +56,8 @@ export type LicenseKeyCountAggregateOutputType = {
lastValidatedAt: number lastValidatedAt: number
userId: number userId: number
email: number email: number
systemName: number
systemNameLower: number
_all: number _all: number
} }
@@ -64,6 +70,8 @@ export type LicenseKeyMinAggregateInputType = {
lastValidatedAt?: true lastValidatedAt?: true
userId?: true userId?: true
email?: true email?: true
systemName?: true
systemNameLower?: true
} }
export type LicenseKeyMaxAggregateInputType = { export type LicenseKeyMaxAggregateInputType = {
@@ -74,6 +82,8 @@ export type LicenseKeyMaxAggregateInputType = {
lastValidatedAt?: true lastValidatedAt?: true
userId?: true userId?: true
email?: true email?: true
systemName?: true
systemNameLower?: true
} }
export type LicenseKeyCountAggregateInputType = { export type LicenseKeyCountAggregateInputType = {
@@ -84,6 +94,8 @@ export type LicenseKeyCountAggregateInputType = {
lastValidatedAt?: true lastValidatedAt?: true
userId?: true userId?: true
email?: true email?: true
systemName?: true
systemNameLower?: true
_all?: true _all?: true
} }
@@ -167,6 +179,8 @@ export type LicenseKeyGroupByOutputType = {
lastValidatedAt: Date | null lastValidatedAt: Date | null
userId: string | null userId: string | null
email: string | null email: string | null
systemName: string | null
systemNameLower: string | null
_count: LicenseKeyCountAggregateOutputType | null _count: LicenseKeyCountAggregateOutputType | null
_min: LicenseKeyMinAggregateOutputType | null _min: LicenseKeyMinAggregateOutputType | null
_max: LicenseKeyMaxAggregateOutputType | null _max: LicenseKeyMaxAggregateOutputType | null
@@ -198,7 +212,10 @@ export type LicenseKeyWhereInput = {
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
systemName?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
systemNameLower?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
} }
export type LicenseKeyOrderByWithRelationInput = { export type LicenseKeyOrderByWithRelationInput = {
@@ -209,12 +226,16 @@ export type LicenseKeyOrderByWithRelationInput = {
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder email?: Prisma.SortOrderInput | Prisma.SortOrder
systemName?: Prisma.SortOrderInput | Prisma.SortOrder
systemNameLower?: Prisma.SortOrderInput | Prisma.SortOrder
user?: Prisma.UserOrderByWithRelationInput user?: Prisma.UserOrderByWithRelationInput
schedule?: Prisma.ScheduleOrderByWithRelationInput
} }
export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
id?: string id?: string
key?: string key?: string
userId_systemNameLower?: Prisma.LicenseKeyUserIdSystemNameLowerCompoundUniqueInput
AND?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[] AND?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
OR?: Prisma.LicenseKeyWhereInput[] OR?: Prisma.LicenseKeyWhereInput[]
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[] NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
@@ -223,8 +244,11 @@ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
systemName?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
systemNameLower?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
}, "id" | "key"> schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}, "id" | "key" | "userId_systemNameLower">
export type LicenseKeyOrderByWithAggregationInput = { export type LicenseKeyOrderByWithAggregationInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
@@ -234,6 +258,8 @@ export type LicenseKeyOrderByWithAggregationInput = {
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder email?: Prisma.SortOrderInput | Prisma.SortOrder
systemName?: Prisma.SortOrderInput | Prisma.SortOrder
systemNameLower?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.LicenseKeyCountOrderByAggregateInput _count?: Prisma.LicenseKeyCountOrderByAggregateInput
_max?: Prisma.LicenseKeyMaxOrderByAggregateInput _max?: Prisma.LicenseKeyMaxOrderByAggregateInput
_min?: Prisma.LicenseKeyMinOrderByAggregateInput _min?: Prisma.LicenseKeyMinOrderByAggregateInput
@@ -250,6 +276,8 @@ export type LicenseKeyScalarWhereWithAggregatesInput = {
lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
systemName?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
systemNameLower?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
} }
export type LicenseKeyCreateInput = { export type LicenseKeyCreateInput = {
@@ -259,7 +287,10 @@ export type LicenseKeyCreateInput = {
createdAt?: Date | string createdAt?: Date | string
lastValidatedAt?: Date | string | null lastValidatedAt?: Date | string | null
email?: string | null email?: string | null
systemName?: string | null
systemNameLower?: string | null
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutLicenseKeyInput
} }
export type LicenseKeyUncheckedCreateInput = { export type LicenseKeyUncheckedCreateInput = {
@@ -270,6 +301,9 @@ export type LicenseKeyUncheckedCreateInput = {
lastValidatedAt?: Date | string | null lastValidatedAt?: Date | string | null
userId?: string | null userId?: string | null
email?: string | null email?: string | null
systemName?: string | null
systemNameLower?: string | null
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput
} }
export type LicenseKeyUpdateInput = { export type LicenseKeyUpdateInput = {
@@ -279,7 +313,10 @@ export type LicenseKeyUpdateInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutLicenseKeyNestedInput
} }
export type LicenseKeyUncheckedUpdateInput = { export type LicenseKeyUncheckedUpdateInput = {
@@ -290,6 +327,9 @@ export type LicenseKeyUncheckedUpdateInput = {
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput
} }
export type LicenseKeyCreateManyInput = { export type LicenseKeyCreateManyInput = {
@@ -300,6 +340,8 @@ export type LicenseKeyCreateManyInput = {
lastValidatedAt?: Date | string | null lastValidatedAt?: Date | string | null
userId?: string | null userId?: string | null
email?: string | null email?: string | null
systemName?: string | null
systemNameLower?: string | null
} }
export type LicenseKeyUpdateManyMutationInput = { export type LicenseKeyUpdateManyMutationInput = {
@@ -309,6 +351,8 @@ export type LicenseKeyUpdateManyMutationInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
export type LicenseKeyUncheckedUpdateManyInput = { export type LicenseKeyUncheckedUpdateManyInput = {
@@ -319,6 +363,8 @@ export type LicenseKeyUncheckedUpdateManyInput = {
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
export type LicenseKeyListRelationFilter = { export type LicenseKeyListRelationFilter = {
@@ -331,6 +377,11 @@ export type LicenseKeyOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder _count?: Prisma.SortOrder
} }
export type LicenseKeyUserIdSystemNameLowerCompoundUniqueInput = {
userId: string
systemNameLower: string
}
export type LicenseKeyCountOrderByAggregateInput = { export type LicenseKeyCountOrderByAggregateInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
key?: Prisma.SortOrder key?: Prisma.SortOrder
@@ -339,6 +390,8 @@ export type LicenseKeyCountOrderByAggregateInput = {
lastValidatedAt?: Prisma.SortOrder lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder userId?: Prisma.SortOrder
email?: Prisma.SortOrder email?: Prisma.SortOrder
systemName?: Prisma.SortOrder
systemNameLower?: Prisma.SortOrder
} }
export type LicenseKeyMaxOrderByAggregateInput = { export type LicenseKeyMaxOrderByAggregateInput = {
@@ -349,6 +402,8 @@ export type LicenseKeyMaxOrderByAggregateInput = {
lastValidatedAt?: Prisma.SortOrder lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder userId?: Prisma.SortOrder
email?: Prisma.SortOrder email?: Prisma.SortOrder
systemName?: Prisma.SortOrder
systemNameLower?: Prisma.SortOrder
} }
export type LicenseKeyMinOrderByAggregateInput = { export type LicenseKeyMinOrderByAggregateInput = {
@@ -359,6 +414,13 @@ export type LicenseKeyMinOrderByAggregateInput = {
lastValidatedAt?: Prisma.SortOrder lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder userId?: Prisma.SortOrder
email?: Prisma.SortOrder email?: Prisma.SortOrder
systemName?: Prisma.SortOrder
systemNameLower?: Prisma.SortOrder
}
export type LicenseKeyNullableScalarRelationFilter = {
is?: Prisma.LicenseKeyWhereInput | null
isNot?: Prisma.LicenseKeyWhereInput | null
} }
export type LicenseKeyCreateNestedManyWithoutUserInput = { export type LicenseKeyCreateNestedManyWithoutUserInput = {
@@ -403,6 +465,22 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserNestedInput = {
deleteMany?: Prisma.LicenseKeyScalarWhereInput | Prisma.LicenseKeyScalarWhereInput[] deleteMany?: Prisma.LicenseKeyScalarWhereInput | Prisma.LicenseKeyScalarWhereInput[]
} }
export type LicenseKeyCreateNestedOneWithoutScheduleInput = {
create?: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.LicenseKeyCreateOrConnectWithoutScheduleInput
connect?: Prisma.LicenseKeyWhereUniqueInput
}
export type LicenseKeyUpdateOneWithoutScheduleNestedInput = {
create?: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.LicenseKeyCreateOrConnectWithoutScheduleInput
upsert?: Prisma.LicenseKeyUpsertWithoutScheduleInput
disconnect?: Prisma.LicenseKeyWhereInput | boolean
delete?: Prisma.LicenseKeyWhereInput | boolean
connect?: Prisma.LicenseKeyWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.LicenseKeyUpdateToOneWithWhereWithoutScheduleInput, Prisma.LicenseKeyUpdateWithoutScheduleInput>, Prisma.LicenseKeyUncheckedUpdateWithoutScheduleInput>
}
export type LicenseKeyCreateWithoutUserInput = { export type LicenseKeyCreateWithoutUserInput = {
id?: string id?: string
key: string key: string
@@ -410,6 +488,9 @@ export type LicenseKeyCreateWithoutUserInput = {
createdAt?: Date | string createdAt?: Date | string
lastValidatedAt?: Date | string | null lastValidatedAt?: Date | string | null
email?: string | null email?: string | null
systemName?: string | null
systemNameLower?: string | null
schedule?: Prisma.ScheduleCreateNestedOneWithoutLicenseKeyInput
} }
export type LicenseKeyUncheckedCreateWithoutUserInput = { export type LicenseKeyUncheckedCreateWithoutUserInput = {
@@ -419,6 +500,9 @@ export type LicenseKeyUncheckedCreateWithoutUserInput = {
createdAt?: Date | string createdAt?: Date | string
lastValidatedAt?: Date | string | null lastValidatedAt?: Date | string | null
email?: string | null email?: string | null
systemName?: string | null
systemNameLower?: string | null
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput
} }
export type LicenseKeyCreateOrConnectWithoutUserInput = { export type LicenseKeyCreateOrConnectWithoutUserInput = {
@@ -458,6 +542,72 @@ export type LicenseKeyScalarWhereInput = {
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
systemName?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
systemNameLower?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
}
export type LicenseKeyCreateWithoutScheduleInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
}
export type LicenseKeyUncheckedCreateWithoutScheduleInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
userId?: string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
}
export type LicenseKeyCreateOrConnectWithoutScheduleInput = {
where: Prisma.LicenseKeyWhereUniqueInput
create: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
}
export type LicenseKeyUpsertWithoutScheduleInput = {
update: Prisma.XOR<Prisma.LicenseKeyUpdateWithoutScheduleInput, Prisma.LicenseKeyUncheckedUpdateWithoutScheduleInput>
create: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
where?: Prisma.LicenseKeyWhereInput
}
export type LicenseKeyUpdateToOneWithWhereWithoutScheduleInput = {
where?: Prisma.LicenseKeyWhereInput
data: Prisma.XOR<Prisma.LicenseKeyUpdateWithoutScheduleInput, Prisma.LicenseKeyUncheckedUpdateWithoutScheduleInput>
}
export type LicenseKeyUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
}
export type LicenseKeyUncheckedUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
export type LicenseKeyCreateManyUserInput = { export type LicenseKeyCreateManyUserInput = {
@@ -467,6 +617,8 @@ export type LicenseKeyCreateManyUserInput = {
createdAt?: Date | string createdAt?: Date | string
lastValidatedAt?: Date | string | null lastValidatedAt?: Date | string | null
email?: string | null email?: string | null
systemName?: string | null
systemNameLower?: string | null
} }
export type LicenseKeyUpdateWithoutUserInput = { export type LicenseKeyUpdateWithoutUserInput = {
@@ -476,6 +628,9 @@ export type LicenseKeyUpdateWithoutUserInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
schedule?: Prisma.ScheduleUpdateOneWithoutLicenseKeyNestedInput
} }
export type LicenseKeyUncheckedUpdateWithoutUserInput = { export type LicenseKeyUncheckedUpdateWithoutUserInput = {
@@ -485,6 +640,9 @@ export type LicenseKeyUncheckedUpdateWithoutUserInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput
} }
export type LicenseKeyUncheckedUpdateManyWithoutUserInput = { export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
@@ -494,6 +652,8 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
} }
@@ -506,7 +666,10 @@ export type LicenseKeySelect<ExtArgs extends runtime.Types.Extensions.InternalAr
lastValidatedAt?: boolean lastValidatedAt?: boolean
userId?: boolean userId?: boolean
email?: boolean email?: boolean
systemName?: boolean
systemNameLower?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs> user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
schedule?: boolean | Prisma.LicenseKey$scheduleArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]> }, ExtArgs["result"]["licenseKey"]>
export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -517,6 +680,8 @@ export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Ex
lastValidatedAt?: boolean lastValidatedAt?: boolean
userId?: boolean userId?: boolean
email?: boolean email?: boolean
systemName?: boolean
systemNameLower?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs> user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]> }, ExtArgs["result"]["licenseKey"]>
@@ -528,6 +693,8 @@ export type LicenseKeySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Ex
lastValidatedAt?: boolean lastValidatedAt?: boolean
userId?: boolean userId?: boolean
email?: boolean email?: boolean
systemName?: boolean
systemNameLower?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs> user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]> }, ExtArgs["result"]["licenseKey"]>
@@ -539,11 +706,14 @@ export type LicenseKeySelectScalar = {
lastValidatedAt?: boolean lastValidatedAt?: boolean
userId?: boolean userId?: boolean
email?: boolean email?: boolean
systemName?: boolean
systemNameLower?: boolean
} }
export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email", ExtArgs["result"]["licenseKey"]> export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email" | "systemName" | "systemNameLower", ExtArgs["result"]["licenseKey"]>
export type LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs> user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
schedule?: boolean | Prisma.LicenseKey$scheduleArgs<ExtArgs>
} }
export type LicenseKeyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type LicenseKeyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs> user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
@@ -556,6 +726,7 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
name: "LicenseKey" name: "LicenseKey"
objects: { objects: {
user: Prisma.$UserPayload<ExtArgs> | null user: Prisma.$UserPayload<ExtArgs> | null
schedule: Prisma.$SchedulePayload<ExtArgs> | null
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -565,6 +736,8 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
lastValidatedAt: Date | null lastValidatedAt: Date | null
userId: string | null userId: string | null
email: string | null email: string | null
systemName: string | null
systemNameLower: string | null
}, ExtArgs["result"]["licenseKey"]> }, ExtArgs["result"]["licenseKey"]>
composites: {} composites: {}
} }
@@ -960,6 +1133,7 @@ readonly fields: LicenseKeyFieldRefs;
export interface Prisma__LicenseKeyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> { export interface Prisma__LicenseKeyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise" readonly [Symbol.toStringTag]: "PrismaPromise"
user<T extends Prisma.LicenseKey$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> user<T extends Prisma.LicenseKey$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
schedule<T extends Prisma.LicenseKey$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$scheduleArgs<ExtArgs>>): Prisma.Prisma__ScheduleClient<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -996,6 +1170,8 @@ export interface LicenseKeyFieldRefs {
readonly lastValidatedAt: Prisma.FieldRef<"LicenseKey", 'DateTime'> readonly lastValidatedAt: Prisma.FieldRef<"LicenseKey", 'DateTime'>
readonly userId: Prisma.FieldRef<"LicenseKey", 'String'> readonly userId: Prisma.FieldRef<"LicenseKey", 'String'>
readonly email: Prisma.FieldRef<"LicenseKey", 'String'> readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
readonly systemName: Prisma.FieldRef<"LicenseKey", 'String'>
readonly systemNameLower: Prisma.FieldRef<"LicenseKey", 'String'>
} }
@@ -1415,6 +1591,25 @@ export type LicenseKey$userArgs<ExtArgs extends runtime.Types.Extensions.Interna
where?: Prisma.UserWhereInput where?: Prisma.UserWhereInput
} }
/**
* LicenseKey.schedule
*/
export type LicenseKey$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Schedule
*/
select?: Prisma.ScheduleSelect<ExtArgs> | null
/**
* Omit specific fields from the Schedule
*/
omit?: Prisma.ScheduleOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ScheduleInclude<ExtArgs> | null
where?: Prisma.ScheduleWhereInput
}
/** /**
* LicenseKey without action * LicenseKey without action
*/ */
@@ -236,6 +236,7 @@ export type ProclamationWhereInput = {
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput> category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput> group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
} }
export type ProclamationOrderByWithRelationInput = { export type ProclamationOrderByWithRelationInput = {
@@ -249,6 +250,7 @@ export type ProclamationOrderByWithRelationInput = {
category?: Prisma.ProclamationCategoryOrderByWithRelationInput category?: Prisma.ProclamationCategoryOrderByWithRelationInput
group?: Prisma.ProclamationGroupOrderByWithRelationInput group?: Prisma.ProclamationGroupOrderByWithRelationInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
blockProclamations?: Prisma.BlockProclamationsOrderByRelationAggregateInput
} }
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
@@ -265,6 +267,7 @@ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput> category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput> group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
}, "id" | "name"> }, "id" | "name">
export type ProclamationOrderByWithAggregationInput = { export type ProclamationOrderByWithAggregationInput = {
@@ -301,6 +304,7 @@ export type ProclamationCreateInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateInput = { export type ProclamationUncheckedCreateInput = {
@@ -311,6 +315,7 @@ export type ProclamationUncheckedCreateInput = {
categoryId: number categoryId: number
groupId: number groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUpdateInput = { export type ProclamationUpdateInput = {
@@ -321,6 +326,7 @@ export type ProclamationUpdateInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateInput = { export type ProclamationUncheckedUpdateInput = {
@@ -331,6 +337,7 @@ export type ProclamationUncheckedUpdateInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationCreateManyInput = { export type ProclamationCreateManyInput = {
@@ -551,6 +558,20 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryNestedInput = {
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[] deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
} }
export type ProclamationCreateNestedOneWithoutBlockProclamationsInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
}
export type ProclamationUpdateOneRequiredWithoutBlockProclamationsNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
upsert?: Prisma.ProclamationUpsertWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput, Prisma.ProclamationUpdateWithoutBlockProclamationsInput>, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationCreateWithoutGroupInput = { export type ProclamationCreateWithoutGroupInput = {
id?: string id?: string
name: string name: string
@@ -558,6 +579,7 @@ export type ProclamationCreateWithoutGroupInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutGroupInput = { export type ProclamationUncheckedCreateWithoutGroupInput = {
@@ -567,6 +589,7 @@ export type ProclamationUncheckedCreateWithoutGroupInput = {
solfeggioFrequencyId: number solfeggioFrequencyId: number
categoryId: number categoryId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutGroupInput = { export type ProclamationCreateOrConnectWithoutGroupInput = {
@@ -614,6 +637,7 @@ export type ProclamationCreateWithoutUserProclamationsInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = { export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
@@ -623,6 +647,7 @@ export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
solfeggioFrequencyId: number solfeggioFrequencyId: number
categoryId: number categoryId: number
groupId: number groupId: number
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = { export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
@@ -648,6 +673,7 @@ export type ProclamationUpdateWithoutUserProclamationsInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = { export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
@@ -657,6 +683,7 @@ export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationCreateWithoutSolfeggioFrequencyInput = { export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
@@ -666,6 +693,7 @@ export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = { export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
@@ -675,6 +703,7 @@ export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
categoryId: number categoryId: number
groupId: number groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = { export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = {
@@ -710,6 +739,7 @@ export type ProclamationCreateWithoutCategoryInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutCategoryInput = { export type ProclamationUncheckedCreateWithoutCategoryInput = {
@@ -719,6 +749,7 @@ export type ProclamationUncheckedCreateWithoutCategoryInput = {
solfeggioFrequencyId: number solfeggioFrequencyId: number
groupId: number groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutCategoryInput = { export type ProclamationCreateOrConnectWithoutCategoryInput = {
@@ -747,6 +778,62 @@ export type ProclamationUpdateManyWithWhereWithoutCategoryInput = {
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput> data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput>
} }
export type ProclamationCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutBlockProclamationsInput = {
where: Prisma.ProclamationWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
}
export type ProclamationUpsertWithoutBlockProclamationsInput = {
update: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
where?: Prisma.ProclamationWhereInput
}
export type ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput = {
where?: Prisma.ProclamationWhereInput
data: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManyGroupInput = { export type ProclamationCreateManyGroupInput = {
id?: string id?: string
name: string name: string
@@ -762,6 +849,7 @@ export type ProclamationUpdateWithoutGroupInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutGroupInput = { export type ProclamationUncheckedUpdateWithoutGroupInput = {
@@ -771,6 +859,7 @@ export type ProclamationUncheckedUpdateWithoutGroupInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateManyWithoutGroupInput = { export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
@@ -796,6 +885,7 @@ export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = { export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
@@ -805,6 +895,7 @@ export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = { export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
@@ -830,6 +921,7 @@ export type ProclamationUpdateWithoutCategoryInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutCategoryInput = { export type ProclamationUncheckedUpdateWithoutCategoryInput = {
@@ -839,6 +931,7 @@ export type ProclamationUncheckedUpdateWithoutCategoryInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateManyWithoutCategoryInput = { export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
@@ -856,10 +949,12 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
export type ProclamationCountOutputType = { export type ProclamationCountOutputType = {
userProclamations: number userProclamations: number
blockProclamations: number
} }
export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs
blockProclamations?: boolean | ProclamationCountOutputTypeCountBlockProclamationsArgs
} }
/** /**
@@ -879,6 +974,13 @@ export type ProclamationCountOutputTypeCountUserProclamationsArgs<ExtArgs extend
where?: Prisma.UserProclamationsWhereInput where?: Prisma.UserProclamationsWhereInput
} }
/**
* ProclamationCountOutputType without action
*/
export type ProclamationCountOutputTypeCountBlockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.BlockProclamationsWhereInput
}
export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -891,6 +993,7 @@ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.Internal
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs> category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs> group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]> }, ExtArgs["result"]["proclamation"]>
@@ -933,6 +1036,7 @@ export type ProclamationInclude<ExtArgs extends runtime.Types.Extensions.Interna
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs> category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs> group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
} }
export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -953,6 +1057,7 @@ export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.Intern
category: Prisma.$ProclamationCategoryPayload<ExtArgs> category: Prisma.$ProclamationCategoryPayload<ExtArgs>
group: Prisma.$ProclamationGroupPayload<ExtArgs> group: Prisma.$ProclamationGroupPayload<ExtArgs>
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[] userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
blockProclamations: Prisma.$BlockProclamationsPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1359,6 +1464,7 @@ export interface Prisma__ProclamationClient<T, Null = never, ExtArgs extends run
category<T extends Prisma.ProclamationCategoryDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategoryDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> category<T extends Prisma.ProclamationCategoryDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategoryDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
group<T extends Prisma.ProclamationGroupDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationGroupDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationGroupClient<runtime.Types.Result.GetResult<Prisma.$ProclamationGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> group<T extends Prisma.ProclamationGroupDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationGroupDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationGroupClient<runtime.Types.Result.GetResult<Prisma.$ProclamationGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
userProclamations<T extends Prisma.Proclamation$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> userProclamations<T extends Prisma.Proclamation$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
blockProclamations<T extends Prisma.Proclamation$blockProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$blockProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1818,6 +1924,30 @@ export type Proclamation$userProclamationsArgs<ExtArgs extends runtime.Types.Ext
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[] distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
} }
/**
* Proclamation.blockProclamations
*/
export type Proclamation$blockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the BlockProclamations
*/
select?: Prisma.BlockProclamationsSelect<ExtArgs> | null
/**
* Omit specific fields from the BlockProclamations
*/
omit?: Prisma.BlockProclamationsOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.BlockProclamationsInclude<ExtArgs> | null
where?: Prisma.BlockProclamationsWhereInput
orderBy?: Prisma.BlockProclamationsOrderByWithRelationInput | Prisma.BlockProclamationsOrderByWithRelationInput[]
cursor?: Prisma.BlockProclamationsWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.BlockProclamationsScalarFieldEnum | Prisma.BlockProclamationsScalarFieldEnum[]
}
/** /**
* Proclamation without action * Proclamation without action
*/ */
File diff suppressed because it is too large Load Diff
+164
View File
@@ -246,6 +246,7 @@ export type UserWhereInput = {
profiles?: Prisma.ProfileListRelationFilter profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
schedules?: Prisma.ScheduleListRelationFilter
} }
export type UserOrderByWithRelationInput = { export type UserOrderByWithRelationInput = {
@@ -263,6 +264,7 @@ export type UserOrderByWithRelationInput = {
profiles?: Prisma.ProfileOrderByRelationAggregateInput profiles?: Prisma.ProfileOrderByRelationAggregateInput
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
schedules?: Prisma.ScheduleOrderByRelationAggregateInput
} }
export type UserWhereUniqueInput = Prisma.AtLeast<{ export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -283,6 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
profiles?: Prisma.ProfileListRelationFilter profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
schedules?: Prisma.ScheduleListRelationFilter
}, "id" | "email"> }, "id" | "email">
export type UserOrderByWithAggregationInput = { export type UserOrderByWithAggregationInput = {
@@ -329,6 +332,7 @@ export type UserCreateInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateInput = { export type UserUncheckedCreateInput = {
@@ -345,6 +349,7 @@ export type UserUncheckedCreateInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserUpdateInput = { export type UserUpdateInput = {
@@ -361,6 +366,7 @@ export type UserUpdateInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateInput = { export type UserUncheckedUpdateInput = {
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateManyInput = { export type UserCreateManyInput = {
@@ -612,6 +619,22 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput> update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
} }
export type UserCreateNestedOneWithoutSchedulesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneWithoutSchedulesNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput
upsert?: Prisma.UserUpsertWithoutSchedulesInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutSchedulesInput, Prisma.UserUpdateWithoutSchedulesInput>, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
}
export type UserCreateWithoutProfilesInput = { export type UserCreateWithoutProfilesInput = {
id: string id: string
name: string name: string
@@ -625,6 +648,7 @@ export type UserCreateWithoutProfilesInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutProfilesInput = { export type UserUncheckedCreateWithoutProfilesInput = {
@@ -640,6 +664,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutProfilesInput = { export type UserCreateOrConnectWithoutProfilesInput = {
@@ -671,6 +696,7 @@ export type UserUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutProfilesInput = { export type UserUncheckedUpdateWithoutProfilesInput = {
@@ -686,6 +712,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateWithoutSessionsInput = { export type UserCreateWithoutSessionsInput = {
@@ -701,6 +728,7 @@ export type UserCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutSessionsInput = { export type UserUncheckedCreateWithoutSessionsInput = {
@@ -716,6 +744,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutSessionsInput = { export type UserCreateOrConnectWithoutSessionsInput = {
@@ -747,6 +776,7 @@ export type UserUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutSessionsInput = { export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -762,6 +792,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateWithoutAccountsInput = { export type UserCreateWithoutAccountsInput = {
@@ -777,6 +808,7 @@ export type UserCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutAccountsInput = { export type UserUncheckedCreateWithoutAccountsInput = {
@@ -792,6 +824,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutAccountsInput = { export type UserCreateOrConnectWithoutAccountsInput = {
@@ -823,6 +856,7 @@ export type UserUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutAccountsInput = { export type UserUncheckedUpdateWithoutAccountsInput = {
@@ -838,6 +872,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateWithoutRoleInput = { export type UserCreateWithoutRoleInput = {
@@ -853,6 +888,7 @@ export type UserCreateWithoutRoleInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutRoleInput = { export type UserUncheckedCreateWithoutRoleInput = {
@@ -868,6 +904,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutRoleInput = { export type UserCreateOrConnectWithoutRoleInput = {
@@ -923,6 +960,7 @@ export type UserCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutUserProclamationsInput = { export type UserUncheckedCreateWithoutUserProclamationsInput = {
@@ -938,6 +976,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutUserProclamationsInput = { export type UserCreateOrConnectWithoutUserProclamationsInput = {
@@ -969,6 +1008,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutUserProclamationsInput = { export type UserUncheckedUpdateWithoutUserProclamationsInput = {
@@ -984,6 +1024,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateWithoutLicenseKeysInput = { export type UserCreateWithoutLicenseKeysInput = {
@@ -999,6 +1040,7 @@ export type UserCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutLicenseKeysInput = { export type UserUncheckedCreateWithoutLicenseKeysInput = {
@@ -1014,6 +1056,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutLicenseKeysInput = { export type UserCreateOrConnectWithoutLicenseKeysInput = {
@@ -1045,6 +1088,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutLicenseKeysInput = { export type UserUncheckedUpdateWithoutLicenseKeysInput = {
@@ -1060,6 +1104,87 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutSchedulesInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutSchedulesInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutSchedulesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
}
export type UserUpsertWithoutSchedulesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutSchedulesInput, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutSchedulesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutSchedulesInput, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
}
export type UserUpdateWithoutSchedulesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutSchedulesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateManyRoleInput = { export type UserCreateManyRoleInput = {
@@ -1085,6 +1210,7 @@ export type UserUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutRoleInput = { export type UserUncheckedUpdateWithoutRoleInput = {
@@ -1100,6 +1226,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateManyWithoutRoleInput = { export type UserUncheckedUpdateManyWithoutRoleInput = {
@@ -1123,6 +1250,7 @@ export type UserCountOutputType = {
profiles: number profiles: number
licenseKeys: number licenseKeys: number
userProclamations: number userProclamations: number
schedules: number
} }
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1131,6 +1259,7 @@ export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.I
profiles?: boolean | UserCountOutputTypeCountProfilesArgs profiles?: boolean | UserCountOutputTypeCountProfilesArgs
licenseKeys?: boolean | UserCountOutputTypeCountLicenseKeysArgs licenseKeys?: boolean | UserCountOutputTypeCountLicenseKeysArgs
userProclamations?: boolean | UserCountOutputTypeCountUserProclamationsArgs userProclamations?: boolean | UserCountOutputTypeCountUserProclamationsArgs
schedules?: boolean | UserCountOutputTypeCountSchedulesArgs
} }
/** /**
@@ -1178,6 +1307,13 @@ export type UserCountOutputTypeCountUserProclamationsArgs<ExtArgs extends runtim
where?: Prisma.UserProclamationsWhereInput where?: Prisma.UserProclamationsWhereInput
} }
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountSchedulesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ScheduleWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -1194,6 +1330,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs> profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs> licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedules?: boolean | Prisma.User$schedulesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
@@ -1240,6 +1377,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs> profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs> licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedules?: boolean | Prisma.User$schedulesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
} }
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1258,6 +1396,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles: Prisma.$ProfilePayload<ExtArgs>[] profiles: Prisma.$ProfilePayload<ExtArgs>[]
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[] licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[] userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
schedules: Prisma.$SchedulePayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1668,6 +1807,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
schedules<T extends Prisma.User$schedulesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$schedulesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -2244,6 +2384,30 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[] distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
} }
/**
* User.schedules
*/
export type User$schedulesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Schedule
*/
select?: Prisma.ScheduleSelect<ExtArgs> | null
/**
* Omit specific fields from the Schedule
*/
omit?: Prisma.ScheduleOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ScheduleInclude<ExtArgs> | null
where?: Prisma.ScheduleWhereInput
orderBy?: Prisma.ScheduleOrderByWithRelationInput | Prisma.ScheduleOrderByWithRelationInput[]
cursor?: Prisma.ScheduleWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ScheduleScalarFieldEnum | Prisma.ScheduleScalarFieldEnum[]
}
/** /**
* User without action * User without action
*/ */
+381
View File
@@ -0,0 +1,381 @@
import { describe, it, expect } from 'vitest';
import {
blockDuration,
blockSegments,
findOverlap,
findScheduleOverlap,
fiveMinuteOptions,
formatTime,
hmToMinutes,
isOvernight,
parseTimeInput,
snapMinutes,
toHm,
validateBlock,
validateBlockShape,
validateSchedule,
type BlockInput,
type BlockSibling
} from './schedule-utils';
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1'],
...overrides
});
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
id,
startMinutes,
endMinutes
});
describe('formatTime', () => {
it('formats 12-hour clock labels', () => {
expect(formatTime(0)).toBe('12:00 AM');
expect(formatTime(6 * 60)).toBe('6:00 AM');
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
expect(formatTime(12 * 60)).toBe('12:00 PM');
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
});
});
describe('toHm', () => {
it('formats 24-hour HH:MM strings', () => {
expect(toHm(0)).toBe('00:00');
expect(toHm(6 * 60)).toBe('06:00');
expect(toHm(23 * 60 + 59)).toBe('23:59');
});
});
describe('hmToMinutes', () => {
it('parses 24-hour HH:MM strings', () => {
expect(hmToMinutes('00:00')).toBe(0);
expect(hmToMinutes('07:05')).toBe(7 * 60 + 5);
expect(hmToMinutes('23:59')).toBe(23 * 60 + 59);
});
it('rejects malformed or out-of-range values', () => {
expect(hmToMinutes('7:05')).toBeNull();
expect(hmToMinutes('24:00')).toBeNull();
expect(hmToMinutes('12:60')).toBeNull();
expect(hmToMinutes('')).toBeNull();
});
});
describe('parseTimeInput', () => {
it('parses tolerant 12-hour input', () => {
expect(parseTimeInput('7:05 pm')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
expect(parseTimeInput('7:05PM')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
expect(parseTimeInput('7pm')).toEqual({ ok: true, minutes: 19 * 60 });
expect(parseTimeInput('7:7')).toEqual({ ok: true, minutes: 7 * 60 + 7 });
expect(parseTimeInput('12:00 am')).toEqual({ ok: true, minutes: 0 });
expect(parseTimeInput('12:30 PM')).toEqual({ ok: true, minutes: 12 * 60 + 30 });
expect(parseTimeInput('11:59 pm')).toEqual({ ok: true, minutes: 23 * 60 + 59 });
expect(parseTimeInput('0:30')).toEqual({ ok: true, minutes: 30 });
});
it('rejects ambiguous or out-of-range input', () => {
expect(parseTimeInput('13:00 PM').ok).toBe(false);
expect(parseTimeInput('7:70').ok).toBe(false);
expect(parseTimeInput('25:00').ok).toBe(false);
expect(parseTimeInput('').ok).toBe(false);
expect(parseTimeInput('noon').ok).toBe(false);
});
});
describe('fiveMinuteOptions', () => {
it('lists every 5-minute step across the day', () => {
const options = fiveMinuteOptions();
expect(options[0]).toBe(0);
expect(options[1]).toBe(5);
expect(options[options.length - 1]).toBe(23 * 60 + 55);
expect(options).toHaveLength((24 * 60) / 5);
});
});
describe('snapMinutes', () => {
it('rounds to the nearest 15 minutes and clamps to the day', () => {
expect(snapMinutes(0)).toBe(0);
expect(snapMinutes(7)).toBe(0);
expect(snapMinutes(8)).toBe(15);
expect(snapMinutes(370)).toBe(375);
expect(snapMinutes(-10)).toBe(0);
expect(snapMinutes(2000)).toBe(1440);
});
});
describe('isOvernight', () => {
it('is true when the end clock time is before the start', () => {
expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true);
expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true);
});
it('is false for same-day ranges and a full-day range', () => {
expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false);
expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false);
});
});
describe('blockDuration', () => {
it('counts a same-day range directly', () => {
expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120);
});
it('counts an overnight range as wrapping to the next day', () => {
expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720);
});
it('treats an end of midnight as wrapping from the start', () => {
expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120);
});
});
describe('blockSegments', () => {
it('returns a single segment for a same-day range', () => {
expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]);
});
it('splits an overnight range at midnight', () => {
expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([
[21 * 60, 1440],
[0, 9 * 60]
]);
});
it('returns no segments for a zero-length range', () => {
expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]);
});
});
describe('findOverlap', () => {
it('returns null for adjacent ranges', () => {
expect(
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
).toBeNull();
});
it('returns the sibling for a partial overlap', () => {
const other = sibling(7 * 60, 9 * 60);
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping an early-morning block', () => {
const other = sibling(8 * 60, 10 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping a late-evening block', () => {
const other = sibling(20 * 60, 22 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('allows an overnight block and its exact complement (adjacent at both ends)', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)])
).toBeNull();
});
it('allows an overnight block and a midday block it does not touch', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)])
).toBeNull();
});
});
describe('validateBlock', () => {
it('accepts a valid block with no siblings', () => {
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
});
it('rejects equal start and end times', () => {
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
expect(result).toEqual({
ok: false,
error: "Start and end time can't be the same."
});
});
it('rejects a time outside the 24-hour range', () => {
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('rejects a negative start time', () => {
expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('accepts an overnight block', () => {
expect(
validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], {
isCreate: true
})
).toEqual({ ok: true });
});
it('rejects an overnight block that overlaps another and names it', () => {
const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 8:00 AM–10:00 AM block.'
});
});
it('requires at least one proclamation', () => {
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
ok: false,
error: 'Select at least one proclamation.'
});
});
it('enforces the four-block maximum on create', () => {
const siblings = [
sibling(0, 60, 'a'),
sibling(60, 120, 'b'),
sibling(120, 180, 'c'),
sibling(180, 240, 'd')
];
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
isCreate: true
});
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
});
it('allows adjacent blocks (end exactly equals next start)', () => {
expect(
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
).toEqual({
ok: true
});
});
it('rejects an exact duplicate range and names the conflicting block', () => {
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
});
});
it('rejects a partial overlap', () => {
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result.ok).toBe(false);
});
it('rejects a block that contains another block', () => {
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
sibling(6 * 60, 8 * 60)
]);
expect(result.ok).toBe(false);
});
it('does not apply the maximum when updating (isCreate false)', () => {
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
});
});
// ─── Schedule-wide validation (daily + weekly) ─────────────────────────
const block = (
startMinutes: number,
endMinutes: number,
day: string | null = null,
proclamationIds: string[] = ['p1']
) => ({ day, startMinutes, endMinutes, proclamationIds });
describe('validateBlockShape', () => {
it('rejects start == end', () => {
expect(validateBlockShape(block(360, 360))).toEqual({
ok: false,
error: "Start and end time can't be the same."
});
});
it('rejects an empty block', () => {
expect(validateBlockShape(block(360, 420, null, []))).toEqual({
ok: false,
error: 'Select at least one proclamation.'
});
});
it('accepts a single-minute block', () => {
expect(validateBlockShape(block(360, 361))).toEqual({ ok: true });
});
});
describe('validateSchedule daily', () => {
it('accepts adjacent blocks (touching boundaries)', () => {
expect(validateSchedule('daily', [block(360, 420), block(420, 510)])).toEqual({ ok: true });
});
it('accepts a single-minute block', () => {
expect(validateSchedule('daily', [block(360, 361)])).toEqual({ ok: true });
});
it('rejects overlapping blocks', () => {
expect(validateSchedule('daily', [block(360, 480), block(420, 540)]).ok).toBe(false);
});
it('rejects a midnight-crossing block that spills into an early block', () => {
// 21:00→09:00 covers [0,540) on the next day; 08:00→08:30 overlaps.
expect(validateSchedule('daily', [block(1260, 540, null), block(480, 510, null)]).ok).toBe(
false
);
});
it('accepts a crossing block and its exact complement', () => {
expect(validateSchedule('daily', [block(1260, 540, null), block(540, 1260, null)])).toEqual({
ok: true
});
});
});
describe('validateSchedule weekly', () => {
it('applies rules per day', () => {
expect(validateSchedule('weekly', [block(360, 420, 'mon'), block(360, 420, 'tue')])).toEqual({
ok: true
});
});
it('rejects overlaps within the same day', () => {
expect(validateSchedule('weekly', [block(360, 480, 'wed'), block(420, 540, 'wed')]).ok).toBe(
false
);
});
it('rejects Sunday midnight-crossing spilling into Monday', () => {
expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(0, 120, 'mon')]).ok).toBe(
false
);
});
it('accepts a Sunday crossing block adjacent to a Monday block', () => {
expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(60, 120, 'mon')])).toEqual({
ok: true
});
});
it('rejects a Saturday crossing spilling into Sunday', () => {
expect(validateSchedule('weekly', [block(1380, 60, 'sat'), block(30, 90, 'sun')]).ok).toBe(
false
);
});
});
describe('findScheduleOverlap', () => {
it('returns null for gaps', () => {
expect(findScheduleOverlap('daily', [block(360, 420), block(600, 660)])).toBeNull();
});
});
+398
View File
@@ -0,0 +1,398 @@
// Isomorphic, dependency-free schedule helpers. Used by the server service
// layer, the v2 serializers and the client-side Block modal (for live
// pre-validation), so it must not import Prisma or any server-only code.
export const MAX_BLOCKS = 4;
export const MINUTES_PER_DAY = 24 * 60;
export const SNAP_MINUTES = 15;
export const MINUTE_STEP = 5;
export type BlockSibling = {
id?: string;
startMinutes: number;
endMinutes: number;
};
export type BlockInput = {
startMinutes: number;
endMinutes: number;
proclamationIds: string[];
/** When true the kiosk enables dynamic audio for the block's duration. */
dynamicAudio?: boolean;
};
export type ValidationResult = { ok: true } | { ok: false; error: string };
// ─── Schedule-wide validation (daily + weekly, midnight spillover) ─────
export type ScheduleType = 'daily' | 'weekly';
export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
export type WeekDay = (typeof WEEK_DAYS)[number];
/** A Block as seen by the validator. `day` is null for daily schedules. */
export type ValidationBlock = {
id?: string;
day?: string | null;
startMinutes: number;
endMinutes: number;
proclamationIds?: string[];
};
/** Block length in minutes, counting a wrap past midnight as the next day. */
function blockLinearLength(block: { startMinutes: number; endMinutes: number }): number {
return block.endMinutes > block.startMinutes
? block.endMinutes - block.startMinutes
: block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
}
/** Linear half-open interval `[start, end)` relative to `dayIndex`. */
function dayInterval(
block: { startMinutes: number; endMinutes: number },
dayIndex: number
): [number, number] {
const base = dayIndex * MINUTES_PER_DAY;
const start = base + block.startMinutes;
return [start, start + blockLinearLength(block)];
}
function isCrossing(block: { startMinutes: number; endMinutes: number }): boolean {
return block.endMinutes < block.startMinutes;
}
function blocksForDay(blocks: ValidationBlock[], dayIndex: number): ValidationBlock[] {
const day = WEEK_DAYS[dayIndex];
return blocks.filter((b) => (b.day ?? null) === day);
}
/**
* First overlapping pair of Blocks in a schedule, or null. Implements the
* contract's overlap rules: touching boundaries are fine; a midnight-crossing
* Block spills into the next day (weekly Sunday→Monday) and must not overlap the
* next day's early Blocks.
*/
export function findScheduleOverlap(
type: ScheduleType,
blocks: ValidationBlock[]
): { a: ValidationBlock; b: ValidationBlock } | null {
if (type === 'daily') {
// Daily is a single day list that wraps onto itself: a crossing Block
// spills into the next day's copy of the same list. First check the
// same-day intervals, then every crossing Block against the next day.
const intervals = blocks.map((b) => ({ block: b, interval: dayInterval(b, 0) }));
const sameDay = firstPairOverlap(intervals);
if (sameDay) return sameDay;
for (const crossing of blocks.filter(isCrossing)) {
// The crossing Block's post-midnight tail, expressed in this day's
// 0-based frame (it spans [0, endMinutes)).
const spill: IntervalEntry = {
block: crossing,
interval: [0, crossing.endMinutes]
};
for (const { block, interval } of intervals) {
if (block === crossing) continue;
if (segmentsIntersect(spill.interval, interval)) {
return { a: crossing, b: block };
}
}
}
return null;
}
for (let d = 0; d < WEEK_DAYS.length; d++) {
const dayBlocks = blocksForDay(blocks, d);
const intervals = dayBlocks.map((b) => ({ block: b, interval: dayInterval(b, d) }));
// Same-day overlaps (a crossing Block's late segment vs the same day).
const pair = firstPairOverlap(intervals);
if (pair) return pair;
// Spillover from the previous day's crossing Blocks into this day's early
// Blocks. All spills start at midnight, so their union is [0, max(end)).
const prevIndex = (d - 1 + WEEK_DAYS.length) % WEEK_DAYS.length;
const prevCrossing = blocksForDay(blocks, prevIndex).filter(isCrossing);
for (const prev of prevCrossing) {
const spill: [number, number] = [d * MINUTES_PER_DAY, d * MINUTES_PER_DAY + prev.endMinutes];
for (const { block, interval } of intervals) {
if (segmentsIntersect(spill, interval)) {
return { a: prev, b: block };
}
}
}
}
return null;
}
type IntervalEntry = { block: ValidationBlock; interval: [number, number] };
function firstPairOverlap(
entries: IntervalEntry[]
): { a: ValidationBlock; b: ValidationBlock } | null {
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
if (segmentsIntersect(entries[i].interval, entries[j].interval)) {
return { a: entries[i].block, b: entries[j].block };
}
}
}
return null;
}
/** Validate one Block's shape (time range + at least one Proclamation). */
export function validateBlockShape(block: ValidationBlock): ValidationResult {
const { startMinutes, endMinutes, proclamationIds } = block;
if (
!Number.isInteger(startMinutes) ||
!Number.isInteger(endMinutes) ||
startMinutes < 0 ||
startMinutes > MINUTES_PER_DAY ||
endMinutes < 0 ||
endMinutes > MINUTES_PER_DAY
) {
return { ok: false, error: 'Enter a valid start and end time.' };
}
if (startMinutes === endMinutes) {
return { ok: false, error: "Start and end time can't be the same." };
}
if (block.day != null && !WEEK_DAYS.includes(block.day as WeekDay)) {
return { ok: false, error: 'Pick a valid day of the week.' };
}
if (proclamationIds !== undefined) {
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
return { ok: false, error: 'Select at least one proclamation.' };
}
}
return { ok: true };
}
/**
* Validate a whole schedule. The server is authoritative; the client calls the
* same function for instant feedback.
*/
export function validateSchedule(type: ScheduleType, blocks: ValidationBlock[]): ValidationResult {
for (const block of blocks) {
const shape = validateBlockShape(block);
if (!shape.ok) return shape;
}
const overlap = findScheduleOverlap(type, blocks);
if (overlap) {
return { ok: false, error: overlapError(overlap.a) };
}
return { ok: true };
}
/**
* Validate a candidate Block against the schedule's other Blocks (which must
* exclude the candidate when editing).
*/
export function validateBlockAgainstSchedule(
type: ScheduleType,
candidate: ValidationBlock,
siblings: ValidationBlock[]
): ValidationResult {
const shape = validateBlockShape(candidate);
if (!shape.ok) return shape;
const overlap = findScheduleOverlap(type, [...siblings, candidate]);
if (overlap) {
return { ok: false, error: overlapError(overlap.a) };
}
return { ok: true };
}
function overlapError(a: ValidationBlock): string {
return `This overlaps with your ${formatTime(a.startMinutes)}–${formatTime(a.endMinutes)} block.`;
}
function normalizeMinutes(minutes: number): number {
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
}
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
export function formatTime(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours24 = Math.floor(normalized / 60);
const mins = normalized % 60;
const period = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
}
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
export function toHm(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours = Math.floor(normalized / 60);
const mins = normalized % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/**
* Parse a 24-hour `HH:MM` string into minutes from midnight. Returns null when
* the input is not a valid 24-hour time.
*/
export function hmToMinutes(hm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hm.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
}
export type TimeInputResult = { ok: true; minutes: number } | { ok: false; error: string };
/**
* Parse a user-typed 12-hour clock time into minutes from midnight. Tolerant of
* "7:05 pm", "7:05PM", "7pm", "7:7", "12:00 am". A period is optional; when
* present the hour must be 1-12, so "13:00 PM" is rejected. Minutes may be any
* value 00-59, not just the 5-minute steps.
*/
export function parseTimeInput(raw: string): TimeInputResult {
const text = raw.trim().toLowerCase();
if (!text) return { ok: false, error: 'Enter a time.' };
const match = /^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm|a|p)?$/.exec(text);
if (!match) return { ok: false, error: 'Enter a time like 7:05 PM.' };
const hour = Number(match[1]);
const minute = match[2] === undefined ? 0 : Number(match[2]);
const period = match[3];
if (minute > 59) return { ok: false, error: 'Minutes must be 00-59.' };
if (period) {
if (hour < 1 || hour > 12) {
return { ok: false, error: 'Hour must be 1-12 when AM/PM is given.' };
}
const isPm = period === 'pm' || period === 'p';
const hours24 = (hour % 12) + (isPm ? 12 : 0);
return { ok: true, minutes: hours24 * 60 + minute };
}
if (hour > 23) return { ok: false, error: 'Hour must be 0-23.' };
return { ok: true, minutes: hour * 60 + minute };
}
/** The 5-minute clock options (00:00, 00:05, ..., 23:55) in play order. */
export function fiveMinuteOptions(): number[] {
const options: number[] = [];
for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += 5) {
options.push(minutes);
}
return options;
}
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
export function snapMinutes(minutes: number): number {
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
}
/** A Block whose end clock time is at or before its start wraps past midnight. */
export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean {
return block.endMinutes <= block.startMinutes;
}
/** Block length in minutes, counting a wrap past midnight as the next day. */
export function blockDuration(block: { startMinutes: number; endMinutes: number }): number {
if (block.endMinutes > block.startMinutes) {
return block.endMinutes - block.startMinutes;
}
return block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
}
/**
* Split a Block into the linear half-open `[start, end)` segments it occupies
* within the 24h day. An overnight block produces two segments (start→midnight
* and midnight→end); a zero-length block produces none.
*/
export function blockSegments(block: {
startMinutes: number;
endMinutes: number;
}): [number, number][] {
if (block.startMinutes < block.endMinutes) {
return [[block.startMinutes, block.endMinutes]];
}
if (block.startMinutes > block.endMinutes) {
return [
[block.startMinutes, MINUTES_PER_DAY],
[0, block.endMinutes]
];
}
return [];
}
function segmentsIntersect(a: [number, number], b: [number, number]): boolean {
return a[0] < b[1] && b[0] < a[1];
}
/**
* First sibling whose occupied time intersects the input. Works for overnight
* (midnight-crossing) blocks by comparing their linear segments; ranges that
* only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap.
*/
export function findOverlap(
input: { startMinutes: number; endMinutes: number },
siblings: BlockSibling[]
): BlockSibling | null {
const inputSegments = blockSegments(input);
for (const sibling of siblings) {
const siblingSegments = blockSegments(sibling);
for (const segment of inputSegments) {
for (const other of siblingSegments) {
if (segmentsIntersect(segment, other)) return sibling;
}
}
}
return null;
}
/**
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
* the Block being edited. `isCreate` enables the 4-block maximum (an update
* never increases the count).
*/
export function validateBlock(
input: BlockInput,
siblings: BlockSibling[],
opts: { isCreate?: boolean } = {}
): ValidationResult {
const { startMinutes, endMinutes, proclamationIds } = input;
if (
!Number.isInteger(startMinutes) ||
!Number.isInteger(endMinutes) ||
startMinutes < 0 ||
startMinutes > MINUTES_PER_DAY ||
endMinutes < 0 ||
endMinutes > MINUTES_PER_DAY
) {
return { ok: false, error: 'Enter a valid start and end time.' };
}
// Equal clock times would be a zero- or full-length block; both are
// ambiguous, so reject them. Overnight ranges (end before start) are valid.
if (startMinutes === endMinutes) {
return { ok: false, error: "Start and end time can't be the same." };
}
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
return { ok: false, error: 'Select at least one proclamation.' };
}
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
}
const overlap = findOverlap(input, siblings);
if (overlap) {
return {
ok: false,
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
overlap.endMinutes
)} block.`
};
}
return { ok: true };
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PrismaClient } from '$lib/generated/prisma/client';
import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import {
getScheduleForLicense,
setScheduleType,
createBlock,
updateBlock,
copySchedule,
listBlocksForLicense,
scheduleBlocksToInput,
type OwnedLicense
} from '$lib/server/schedule';
import { buildSchedulePayload } from '$lib/server/v2-serializers';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const K_A1 = 'ALCE-AAAA-AAAA-0001';
const K_A2 = 'ALCE-AAAA-AAAA-0002';
const K_B1 = 'BOBB-BBBB-BBBB-0001';
const results: { id: string; pass: boolean; note: string }[] = [];
let PROCS: string[] = [];
async function licenseByKey(key: string) {
return prisma.licenseKey.findUnique({ where: { key } });
}
async function ownedFor(email: string): Promise<OwnedLicense[]> {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new Error('no user');
return prisma.licenseKey.findMany({
where: { userId: user.id },
select: { id: true, systemName: true, key: true }
});
}
function payloadFor(schedule: Awaited<ReturnType<typeof getScheduleForLicense>>) {
const type = schedule?.type === 'daily' || schedule?.type === 'weekly' ? schedule.type : null;
return buildSchedulePayload(type, scheduleBlocksToInput(schedule?.blocks ?? []));
}
beforeAll(async () => {
const [ka1, ka2, kb1] = await Promise.all([
licenseByKey(K_A1),
licenseByKey(K_A2),
licenseByKey(K_B1)
]);
await prisma.schedule.deleteMany({
where: { licenseKeyId: { in: [ka1!.id, ka2!.id, kb1!.id] } }
});
const procs = await prisma.proclamation.findMany({
where: { name: { startsWith: 'P5 ' } },
select: { id: true },
orderBy: { name: 'asc' }
});
PROCS = procs.map((p) => p.id);
expect(PROCS.length).toBeGreaterThanOrEqual(4);
});
afterAll(async () => {
console.log('\nP5_RESULTS ' + JSON.stringify(results));
await prisma.$disconnect();
await pool.end();
});
const enabled = process.env.P5_INTEGRATION === '1';
describe.skipIf(!enabled)('Phase 5 end-to-end service scenarios', () => {
it('e: create a daily schedule via the Portal path', async () => {
const ka1 = await licenseByKey(K_A1);
await setScheduleType(ka1!.id, 'daily', { confirmed: true });
await createBlock(ka1!.id, {
startMinutes: 7 * 60,
endMinutes: 9 * 60,
proclamationIds: [PROCS[0]],
dynamicAudio: true
});
await createBlock(ka1!.id, {
startMinutes: 18 * 60,
endMinutes: 20 * 60 + 30,
proclamationIds: [PROCS[1]],
dynamicAudio: false
});
const daily = await getScheduleForLicense(ka1!.id);
const p = payloadFor(daily);
results.push({
id: 'e',
pass: p.type === 'daily' && p.daily.length === 2,
note: `type=${p.type} blocks=${p.daily.length}`
});
expect(p.type).toBe('daily');
expect(p.daily).toHaveLength(2);
});
it('f: weekly adjacent + midnight-crossing + Sunday spillover rejection', async () => {
const kb1 = await licenseByKey(K_B1);
await setScheduleType(kb1!.id, 'weekly', { confirmed: true });
await createBlock(kb1!.id, {
day: 'sun',
startMinutes: 23 * 60,
endMinutes: 60,
proclamationIds: [PROCS[2]]
});
await createBlock(kb1!.id, {
day: 'mon',
startMinutes: 6 * 60,
endMinutes: 7 * 60,
proclamationIds: [PROCS[0]]
});
await createBlock(kb1!.id, {
day: 'mon',
startMinutes: 7 * 60,
endMinutes: 8 * 60 + 30,
proclamationIds: [PROCS[1]]
});
const p = payloadFor(await getScheduleForLicense(kb1!.id));
expect(p.type).toBe('weekly');
expect(p.weekly.sun).toHaveLength(1);
expect(p.weekly.sun[0].overnight).toBe(true);
expect(p.weekly.mon).toHaveLength(2);
expect(p.weekly.mon[0].endMinutes).toBe(p.weekly.mon[1].startMinutes);
// Sunday's 23:00->01:00 spills into Monday [00:00,01:00); a Monday 00:00-00:30 block must be rejected.
const rejected = await createBlock(kb1!.id, {
day: 'mon',
startMinutes: 0,
endMinutes: 30,
proclamationIds: [PROCS[3]]
});
const monCount = (await getScheduleForLicense(kb1!.id))!.blocks.filter(
(b) => b.day === 'mon'
).length;
results.push({
id: 'f',
pass: !rejected.ok && monCount === 2,
note: `sun overnight=${p.weekly.sun[0].overnight} mon adjacent=${p.weekly.mon[0].endMinutes === p.weekly.mon[1].startMinutes} spilloverRejected=${!rejected.ok}`
});
expect(rejected.ok).toBe(false);
expect(monCount).toBe(2);
});
it('i: schedules are per-License-Key (A does not affect B)', async () => {
const [ka1, kb1] = await Promise.all([licenseByKey(K_A1), licenseByKey(K_B1)]);
const a = payloadFor(await getScheduleForLicense(ka1!.id));
const b = payloadFor(await getScheduleForLicense(kb1!.id));
results.push({
id: 'i',
pass: a.type === 'daily' && b.type === 'weekly',
note: `A=${a.type} B=${b.type}`
});
expect(a.type).toBe('daily');
expect(b.type).toBe('weekly');
});
it('j: copy A -> second system and verify independence', async () => {
const [ka1, ka2] = await Promise.all([licenseByKey(K_A1), licenseByKey(K_A2)]);
const owned = await ownedFor('p5-alice@example.com');
const copy = await copySchedule(owned, ka1!.id, [ka2!.id]);
expect(copy.ok).toBe(true);
const ka2Before = payloadFor(await getScheduleForLicense(ka2!.id));
expect(ka2Before.type).toBe('daily');
expect(ka2Before.daily).toHaveLength(2);
const src = payloadFor(await getScheduleForLicense(ka1!.id));
expect(src.version).not.toBe(ka2Before.version);
// Edit source; copy must not change.
const blocks = await listBlocksForLicense(ka1!.id);
await updateBlock(ka1!.id, blocks[0].id, {
startMinutes: 6 * 60,
endMinutes: 7 * 60,
proclamationIds: blocks[0].proclamations.map((p) => p.proclamation.id),
dynamicAudio: true
});
const ka2After = payloadFor(await getScheduleForLicense(ka2!.id));
results.push({
id: 'j',
pass: ka2After.version === ka2Before.version && src.version !== ka2Before.version,
note: `copied daily blocks=${ka2Before.daily.length} independent=${ka2After.version === ka2Before.version}`
});
expect(ka2After.version).toBe(ka2Before.version);
});
});
+414
View File
@@ -0,0 +1,414 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// In-memory Prisma double shared by the service and the assertions. It supports
// just the operations `copySchedule` uses, plus a shared store so we can read
// back copies and compute their Kiosk-facing payloads/ETags. It is created via
// vi.hoisted so the `vi.mock` factory (which is hoisted above imports) can see
// it.
const store = vi.hoisted(() => {
type Block = {
id: string;
scheduleId: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { proclamation: { id: string }; sortOrder: number }[];
};
type Schedule = { id: string; licenseKeyId: string | null; type: string | null };
const state = {
schedules: [] as Schedule[],
blocks: [] as Block[],
idCounter: 0,
failOnBlockCreate: false
};
function nextId(prefix: string) {
state.idCounter += 1;
return `${prefix}-${state.idCounter}`;
}
const tx = {
schedule: {
findUnique: async ({ where }: { where: { licenseKeyId?: string; id?: string } }) => {
if (where.licenseKeyId !== undefined) {
return state.schedules.find((s) => s.licenseKeyId === where.licenseKeyId) ?? null;
}
return state.schedules.find((s) => s.id === where.id) ?? null;
},
create: async ({ data }: { data: { licenseKeyId: string; type: string | null } }) => {
const row: Schedule = { id: nextId('sched'), ...data };
state.schedules.push(row);
return row;
},
update: async ({ where, data }: { where: { id: string }; data: Partial<Schedule> }) => {
const row = state.schedules.find((s) => s.id === where.id);
if (!row) throw new Error('missing schedule');
Object.assign(row, data);
return row;
}
},
block: {
deleteMany: async ({ where }: { where: { scheduleId: string } }) => {
state.blocks = state.blocks.filter((b) => b.scheduleId !== where.scheduleId);
return { count: 0 };
},
create: async ({ data }: { data: Record<string, unknown> }) => {
if (state.failOnBlockCreate) throw new Error('db down');
const { proclamations, ...rest } = data as {
proclamations: { create: { proclamationId: string; sortOrder: number }[] };
} & Record<string, unknown>;
const row: Block = {
id: nextId('block'),
scheduleId: rest.scheduleId as string,
day: (rest.day as string | null) ?? null,
startMinutes: rest.startMinutes as number,
endMinutes: rest.endMinutes as number,
dynamicAudio: rest.dynamicAudio as boolean,
proclamations: proclamations.create.map((p) => ({
proclamation: { id: p.proclamationId },
sortOrder: p.sortOrder
}))
};
state.blocks.push(row);
return row;
}
}
};
const transaction = vi.fn(async (fn: (txArg: unknown) => Promise<void>) => {
const scheduleSnapshot = state.schedules.map((s) => ({ ...s }));
const blockSnapshot = state.blocks.map((b) => ({ ...b, proclamations: [...b.proclamations] }));
try {
await fn(tx);
} catch (error) {
state.schedules = scheduleSnapshot;
state.blocks = blockSnapshot;
throw error;
}
});
const scheduleFindUnique = vi.fn();
return { state, tx, transaction, scheduleFindUnique, nextId };
});
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: store.scheduleFindUnique },
$transaction: store.transaction
}
}));
import { buildSchedulePayload } from './v2-serializers';
import { copySchedule, scheduleBlocksToInput, type OwnedLicense } from './schedule';
const owned: OwnedLicense[] = [
{ id: 'lk-src', systemName: 'Living Room', key: 'AAAA-AAAA-AAAA-AAAA' },
{ id: 'lk-t1', systemName: 'Kitchen', key: 'BBBB-BBBB-BBBB-BBBB' },
{ id: 'lk-t2', systemName: null, key: 'CCCC-CCCC-CCCC-CCCC' }
];
/** Seed the source schedule and a full block list. */
function seedSource(
type: 'daily' | 'weekly',
blockSpecs: Partial<(typeof state.blocks)[number]>[]
) {
store.state.schedules = [{ id: 'sched-src', licenseKeyId: 'lk-src', type }];
store.state.blocks = blockSpecs.map((spec, i) => ({
id: `src-block-${i}`,
scheduleId: 'sched-src',
day: spec.day ?? null,
startMinutes: spec.startMinutes ?? 0,
endMinutes: spec.endMinutes ?? 60,
dynamicAudio: spec.dynamicAudio ?? true,
proclamations: spec.proclamations ?? [{ proclamation: { id: 'p1' }, sortOrder: 0 }]
}));
store.scheduleFindUnique.mockImplementation(
async ({ where }: { where: { licenseKeyId: string } }) => {
if (where.licenseKeyId !== 'lk-src') return null;
return {
...store.state.schedules.find((s) => s.licenseKeyId === 'lk-src'),
blocks: store.state.blocks
.filter((b) => b.scheduleId === 'sched-src')
.map((b) => ({ ...b, proclamations: b.proclamations }))
};
}
);
}
const state = store.state;
function payloadFor(licenseKeyId: string) {
const schedule = state.schedules.find((s) => s.licenseKeyId === licenseKeyId) ?? null;
const scheduleBlocks = state.blocks.filter((b) => b.scheduleId === schedule?.id);
return buildSchedulePayload(
schedule?.type === 'daily' || schedule?.type === 'weekly' ? schedule.type : null,
scheduleBlocksToInput(
scheduleBlocks.map((b) => ({
id: b.id,
day: b.day,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamations: b.proclamations.sort((x, y) => x.sortOrder - y.sortOrder).map((p) => p)
}))
)
);
}
beforeEach(() => {
vi.clearAllMocks();
state.idCounter = 0;
state.failOnBlockCreate = false;
state.schedules = [];
state.blocks = [];
});
describe('copySchedule', () => {
it('copies a daily schedule and changes the target version', async () => {
seedSource('daily', [
{ startMinutes: 360, endMinutes: 480, dynamicAudio: true },
{ startMinutes: 1080, endMinutes: 1230, dynamicAudio: false }
]);
const before = payloadFor('lk-t1'); // no schedule yet -> null payload
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
expect(result.ok).toBe(true);
const after = payloadFor('lk-t1');
expect(after.type).toBe('daily');
expect(after.daily).toHaveLength(2);
expect(after.daily[0].dynamicAudio).toBe(true);
expect(after.daily[1].dynamicAudio).toBe(false);
expect(after.version).not.toBe(before.version);
});
it('copies a weekly schedule preserving day layout', async () => {
seedSource('weekly', [
{ day: 'mon', startMinutes: 360, endMinutes: 480 },
{ day: 'sun', startMinutes: 1380, endMinutes: 60 }
]);
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
expect(result.ok).toBe(true);
const after = payloadFor('lk-t1');
expect(after.type).toBe('weekly');
expect(Object.keys(after.weekly).sort()).toEqual([
'fri',
'mon',
'sat',
'sun',
'thu',
'tue',
'wed'
]);
expect(after.weekly.mon).toHaveLength(1);
expect(after.weekly.sun).toHaveLength(1);
expect(after.weekly.tue).toEqual([]);
expect(after.blocks).toEqual([]);
});
it('overwrites an existing target schedule', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
state.schedules.push({ id: 'sched-t1', licenseKeyId: 'lk-t1', type: 'daily' });
state.blocks.push({
id: 'old-block',
scheduleId: 'sched-t1',
day: null,
startMinutes: 1000,
endMinutes: 1030,
dynamicAudio: false,
proclamations: [{ proclamation: { id: 'old' }, sortOrder: 0 }]
});
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
expect(result.ok).toBe(true);
const target = state.blocks.filter((b) => b.scheduleId === 'sched-t1');
expect(target).toHaveLength(1);
expect(target[0].startMinutes).toBe(360);
expect(target[0].proclamations).toEqual([{ proclamation: { id: 'p1' }, sortOrder: 0 }]);
});
it('copies to multiple targets', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
const result = await copySchedule(owned, 'lk-src', ['lk-t1', 'lk-t2']);
expect(result).toMatchObject({ ok: true, copied: 2 });
expect(payloadFor('lk-t1').daily).toHaveLength(1);
expect(payloadFor('lk-t2').daily).toHaveLength(1);
});
it('rolls back every target when one fails', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
state.failOnBlockCreate = true;
const result = await copySchedule(owned, 'lk-src', ['lk-t1', 'lk-t2']);
expect(result.ok).toBe(false);
// No target schedule was left behind.
expect(state.schedules.find((s) => s.licenseKeyId === 'lk-t1')).toBeUndefined();
expect(state.schedules.find((s) => s.licenseKeyId === 'lk-t2')).toBeUndefined();
expect(state.blocks.filter((b) => b.scheduleId !== 'sched-src')).toHaveLength(0);
});
it('rejects a target outside the account', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
const result = await copySchedule(owned, 'lk-src', ['lk-other']);
expect(result).toEqual({ ok: false, error: 'Target system not found.' });
expect(store.transaction).not.toHaveBeenCalled();
});
it('rejects a target equal to the source', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
const result = await copySchedule(owned, 'lk-src', ['lk-src']);
expect(result.ok).toBe(false);
expect(store.transaction).not.toHaveBeenCalled();
});
it('rejects a source outside the account', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
const result = await copySchedule(owned, 'lk-other', ['lk-t1']);
expect(result).toEqual({ ok: false, error: 'Source system not found.' });
});
it('rejects a source with no schedule', async () => {
state.schedules = [];
state.blocks = [];
store.scheduleFindUnique.mockResolvedValue(null);
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
expect(result).toEqual({ ok: false, error: 'The source system has no schedule to copy.' });
});
it('assigns new Block IDs on the copy', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
await copySchedule(owned, 'lk-src', ['lk-t1']);
const copied = state.blocks.filter((b) => b.scheduleId !== 'sched-src');
expect(copied).toHaveLength(1);
expect(copied[0].id).not.toBe('src-block-0');
expect(copied[0].id).toMatch(/^block-/);
});
it('produces independent copies (source edits do not affect targets)', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
await copySchedule(owned, 'lk-src', ['lk-t1']);
const targetVersion = payloadFor('lk-t1').version;
// Edit the source in place.
state.blocks = state.blocks.map((b) =>
b.scheduleId === 'sched-src' ? { ...b, endMinutes: 999 } : b
);
expect(payloadFor('lk-t1').version).toBe(targetVersion);
expect(payloadFor('lk-src').version).not.toBe(targetVersion);
});
it('rejects a target list with no targets', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
const result = await copySchedule(owned, 'lk-src', []);
expect(result).toEqual({ ok: false, error: 'Pick at least one system to copy to.' });
});
it('changes the target ETag to match the served payload version', async () => {
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
await copySchedule(owned, 'lk-src', ['lk-t1']);
const payload = payloadFor('lk-t1');
const etag = `"${payload.version}"`;
const expected = `"${createHash('sha1')
.update(JSON.stringify({ type: payload.type, daily: payload.daily, weekly: payload.weekly }))
.digest('hex')}"`;
expect(etag).toBe(expected);
});
});
describe('copied payload is consumed like any Kiosk schedule', () => {
const readFixture = (name: string) =>
JSON.parse(readFileSync(resolve(process.cwd(), 'docs/fixtures', name), 'utf8'));
it('matches the daily fixture shape after copy (IDs differ)', async () => {
const fixture = readFixture('schedule-daily.json');
// Seed the source from the daily fixture content.
seedSource(
'daily',
fixture.daily.map((b: Record<string, unknown>) => ({
day: null,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamations: (b.proclamationIds as string[]).map((id, i) => ({
proclamation: { id },
sortOrder: i
}))
}))
);
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
expect(result.ok).toBe(true);
const payload = payloadFor('lk-t1');
expect(payload.type).toBe('daily');
// Same times/audio/ids as the fixture; only block IDs differ.
expect(
payload.daily.map((b) => ({
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamationIds: b.proclamationIds
}))
).toEqual(
fixture.daily.map((b: Record<string, unknown>) => ({
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamationIds: b.proclamationIds
}))
);
expect(payload.blocks).toEqual(payload.daily);
});
it('matches the weekly fixture day layout after copy', async () => {
const fixture = readFixture('schedule-weekly.json');
const specs: Record<string, unknown>[] = [];
for (const [day, list] of Object.entries(
fixture.weekly as Record<string, Record<string, unknown>[]>
)) {
for (const b of list) {
specs.push({
day,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamations: (b.proclamationIds as string[]).map((id, i) => ({
proclamation: { id },
sortOrder: i
}))
});
}
}
seedSource('weekly', specs);
await copySchedule(owned, 'lk-src', ['lk-t1']);
const payload = payloadFor('lk-t1');
expect(payload.type).toBe('weekly');
expect(payload.blocks).toEqual([]);
const nonEmptyDays = Object.entries(payload.weekly)
.filter(([, list]) => list.length > 0)
.map(([day]) => day)
.sort();
const fixtureDays = Object.entries(fixture.weekly as Record<string, unknown[]>)
.filter(([, list]) => list.length > 0)
.map(([day]) => day)
.sort();
expect(nonEmptyDays).toEqual(fixtureDays);
});
});
+253
View File
@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
licenseKey: { findUnique: vi.fn() },
block: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
createMany: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn()
},
blockProclamations: { createMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import {
createBlock,
deleteBlock,
getOrCreateScheduleForLicense,
listBlocksForLicense,
setScheduleType,
updateBlock
} from './schedule';
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
const scheduleUpdate = prisma.schedule.update as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
const blockDeleteMany = prisma.block.deleteMany as ReturnType<typeof vi.fn>;
const validInput = {
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1']
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateScheduleForLicense', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
const result = await getOrCreateScheduleForLicense('lk1');
expect(result).toEqual({ id: 's1', licenseKeyId: 'lk1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates a schedule keyed by license when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
await getOrCreateScheduleForLicense('lk1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { licenseKeyId: 'lk1' } });
});
});
describe('listBlocksForLicense', () => {
it('scopes to the license key', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocksForLicense('lk1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { licenseKeyId: 'lk1' } },
include: expect.objectContaining({ proclamations: expect.anything() })
})
);
});
});
describe('setScheduleType', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
});
it('set type when empty', async () => {
blockFindMany.mockResolvedValue([]);
scheduleUpdate.mockResolvedValue({});
const result = await setScheduleType('lk1', 'daily');
expect(result).toEqual({ ok: true });
expect(scheduleUpdate).toHaveBeenCalledWith({
where: { id: 's1' },
data: { type: 'daily' }
});
});
it('requires confirmation to switch a populated schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
blockFindMany.mockResolvedValue([
{ id: 'b1', startMinutes: 0, endMinutes: 60, dynamicAudio: true, proclamations: [] }
]);
const result = await setScheduleType('lk1', 'weekly');
expect(result).toEqual({ ok: false, error: 'confirm' });
expect(scheduleUpdate).not.toHaveBeenCalled();
});
it('clearing to null deletes the blocks', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
blockFindMany.mockResolvedValue([]);
blockDeleteMany.mockResolvedValue({ count: 0 });
scheduleUpdate.mockResolvedValue({});
const result = await setScheduleType('lk1', null, { confirmed: true });
expect(result).toEqual({ ok: true });
expect(blockDeleteMany).toHaveBeenCalledWith({ where: { scheduleId: 's1' } });
expect(scheduleUpdate).toHaveBeenCalledWith({ where: { id: 's1' }, data: { type: null } });
});
});
describe('createBlock', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
});
it('creates a block with deduped proclamations in order', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
const result = await createBlock('lk1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
expect(result).toEqual({ ok: true, id: 'new-block' });
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
day: null,
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: true,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
{ proclamationId: 'p2', sortOrder: 1 }
]
}
}
});
});
it('rejects when no type is set', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
const result = await createBlock('lk1', validInput);
expect(result).toEqual({ ok: false, error: 'Set a schedule type first.' });
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an overlapping block with a specific message', async () => {
blockFindMany.mockResolvedValue([
{ id: 'a', day: null, startMinutes: 7 * 60, endMinutes: 9 * 60, proclamations: [] }
]);
const result = await createBlock('lk1', validInput);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
});
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an empty block before creating', async () => {
blockFindMany.mockResolvedValue([]);
const result = await createBlock('lk1', { ...validInput, proclamationIds: [] });
expect(result).toEqual({ ok: false, error: 'Select at least one proclamation.' });
expect(blockCreate).not.toHaveBeenCalled();
});
});
describe('updateBlock', () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('lk1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
});
it('replaces proclamations and excludes itself from overlap checks', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
blockFindMany.mockResolvedValue([]);
blockUpdate.mockResolvedValue({ id: 'b1' });
const result = await updateBlock('lk1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { scheduleId: 's1', id: { not: 'b1' } }
})
);
expect(blockUpdate).toHaveBeenCalledWith({
where: { id: 'b1' },
data: {
day: null,
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: true,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
}
}
});
});
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('lk1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
});
it('deletes an owned block', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('lk1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
});
});
+512
View File
@@ -0,0 +1,512 @@
// Schedule service layer. Owns the database orchestration and enforcement of
// the Block business rules. Pure validation lives in `$lib/schedule-utils` so
// the client can reuse the exact same rules.
//
// A Schedule belongs to exactly one License Key. `type` is "daily" | "weekly" |
// null (null = no schedule). Daily Blocks have day=null; weekly Blocks carry a
// week day. Times are wall-clock minutes from midnight.
import { prisma } from '$lib/db';
import {
MAX_BLOCKS,
WEEK_DAYS,
validateBlockAgainstSchedule,
validateSchedule,
type ScheduleType,
type ValidationBlock
} from '$lib/schedule-utils';
export {
MAX_BLOCKS,
MINUTES_PER_DAY,
SNAP_MINUTES,
MINUTE_STEP,
WEEK_DAYS,
formatTime,
toHm,
snapMinutes,
findOverlap,
findScheduleOverlap,
validateBlock,
validateBlockAgainstSchedule,
validateBlockShape,
validateSchedule
} from '$lib/schedule-utils';
export type {
BlockInput as LegacyBlockInput,
BlockSibling,
ValidationResult,
ValidationBlock,
ScheduleType
} from '$lib/schedule-utils';
export type BlockInput = {
day?: string | null;
startMinutes: number;
endMinutes: number;
proclamationIds: string[];
dynamicAudio?: boolean;
};
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
export type TypeMutationResult = { ok: true } | { ok: false; error: string };
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
}
function dynamicAudioOrDefault(input: BlockInput): boolean {
return input.dynamicAudio !== false;
}
function isScheduleType(value: unknown): value is ScheduleType {
return value === 'daily' || value === 'weekly';
}
/** Normalise the day for a schedule type: daily always uses null. */
function normaliseDay(type: ScheduleType | null, day: string | null | undefined): string | null {
if (type !== 'weekly') return null;
return day ?? null;
}
// Schedules only need a Proclamation's identity and the labels the portal
// shows. Selecting explicitly (rather than `include`) avoids pulling every
// Proclamation column — notably the potentially large `content` — on each load.
const blockInclude = {
proclamations: {
orderBy: { sortOrder: 'asc' as const },
select: {
sortOrder: true,
proclamation: { select: { id: true, name: true, categoryId: true, groupId: true } }
}
}
};
/** Ensure the License Key has a Schedule row. New rows start with type null. */
export async function getOrCreateScheduleForLicense(licenseKeyId: string) {
const existing = await prisma.schedule.findUnique({ where: { licenseKeyId } });
if (existing) return existing;
return prisma.schedule.create({ data: { licenseKeyId } });
}
/** The Schedule (with Blocks and Proclamations) for a License Key, or null. */
export async function getScheduleForLicense(licenseKeyId: string) {
return prisma.schedule.findUnique({
where: { licenseKeyId },
include: { blocks: { include: blockInclude } }
});
}
/** The Schedule for a License Key string, or null. */
export async function getScheduleForLicenseKey(licenseKey: string) {
const license = await prisma.licenseKey.findUnique({
where: { key: licenseKey },
select: { id: true }
});
if (!license) return { license: null, schedule: null };
const schedule = await getScheduleForLicense(license.id);
return { license, schedule };
}
/** Blocks for a License Key's schedule, ordered by day then start. */
export async function listBlocksForLicense(licenseKeyId: string) {
return prisma.block.findMany({
where: { schedule: { licenseKeyId } },
orderBy: [{ day: 'asc' }, { startMinutes: 'asc' }],
include: blockInclude
});
}
function toValidationBlock(block: {
id?: string;
day: string | null;
startMinutes: number;
endMinutes: number;
proclamationIds?: string[];
}): ValidationBlock {
return {
id: block.id,
day: block.day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
proclamationIds: block.proclamationIds
};
}
/**
* Change (or clear) a Schedule's type. daily→weekly prefills all seven days
* with the daily Blocks; weekly→daily and clearing start empty. Returns an
* error result when confirmation is implicitly required but not given.
*/
export async function setScheduleType(
licenseKeyId: string,
type: ScheduleType | null,
opts: { confirmed?: boolean } = {}
): Promise<TypeMutationResult> {
if (type !== null && !isScheduleType(type)) {
return { ok: false, error: 'Pick a schedule type.' };
}
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
if (schedule.type === type) return { ok: true };
const existing = await prisma.block.findMany({
where: { scheduleId: schedule.id },
include: { proclamations: { orderBy: { sortOrder: 'asc' } } }
});
// Changing an existing, populated schedule requires confirmation.
if (
(schedule.type === 'daily' || schedule.type === 'weekly') &&
type !== null &&
!opts.confirmed
) {
return { ok: false, error: 'confirm' };
}
if (type === 'weekly' && schedule.type === 'daily' && existing.length > 0) {
// Prefill all seven days with a copy of the daily Blocks.
await prisma.block.createMany({
data: existing.flatMap((block) =>
WEEK_DAYS.map((day) => ({
scheduleId: schedule.id,
day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio
}))
)
});
const clones = await prisma.block.findMany({
where: { scheduleId: schedule.id, day: { not: null } }
});
for (const clone of clones) {
const source = existing.find(
(b) =>
b.startMinutes === clone.startMinutes &&
b.endMinutes === clone.endMinutes &&
b.dynamicAudio === clone.dynamicAudio
);
if (source) {
await prisma.blockProclamations.createMany({
data: source.proclamations.map((p, index) => ({
blockId: clone.id,
proclamationId: p.proclamationId,
sortOrder: index
})),
skipDuplicates: true
});
}
}
} else if (type !== 'weekly') {
// weekly→daily, daily→null, weekly→null: clear the Blocks.
await prisma.block.deleteMany({ where: { scheduleId: schedule.id } });
}
await prisma.schedule.update({ where: { id: schedule.id }, data: { type } });
return { ok: true };
}
async function ensureValid(
licenseKeyId: string,
day: string | null,
candidate: BlockInput,
excludeBlockId?: string
): Promise<BlockMutationResult & { scheduleId?: string; type?: ScheduleType | null }> {
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
const type: ScheduleType | null = isScheduleType(schedule.type) ? schedule.type : null;
if (type === null) {
return { ok: false, error: 'Set a schedule type first.' };
}
if (type === 'weekly' && (day === null || !(WEEK_DAYS as readonly string[]).includes(day))) {
return { ok: false, error: 'Pick a valid day of the week.' };
}
const existing = await prisma.block.findMany({
where: {
scheduleId: schedule.id,
...(excludeBlockId ? { id: { not: excludeBlockId } } : {})
},
select: {
id: true,
day: true,
startMinutes: true,
endMinutes: true,
proclamations: { select: { proclamationId: true } }
}
});
const siblings: ValidationBlock[] = existing.map((b) =>
toValidationBlock({
id: b.id,
day: b.day,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
proclamationIds: b.proclamations.map((p) => p.proclamationId)
})
);
const candidateBlock = toValidationBlock({
day,
startMinutes: candidate.startMinutes,
endMinutes: candidate.endMinutes,
proclamationIds: uniqueIds(candidate.proclamationIds)
});
const validation = validateBlockAgainstSchedule(type, candidateBlock, siblings);
if (!validation.ok) return validation;
const sameDayCount = siblings.filter((b) => normaliseDay(type, b.day) === day).length;
if (!excludeBlockId && sameDayCount >= MAX_BLOCKS) {
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks per day.` };
}
return { ok: true, scheduleId: schedule.id, type };
}
export async function createBlock(
licenseKeyId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
const type: ScheduleType | null = isScheduleType(schedule.type) ? schedule.type : null;
const day = normaliseDay(type, input.day);
const checked = await ensureValid(licenseKeyId, day, input);
if (!checked.ok) return { ok: false, error: checked.error };
const proclamationIds = uniqueIds(input.proclamationIds);
const block = await prisma.block.create({
data: {
scheduleId: schedule.id,
day,
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: dynamicAudioOrDefault(input),
proclamations: {
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: block.id };
}
export async function updateBlock(
licenseKeyId: string,
blockId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { licenseKeyId } },
select: { id: true, scheduleId: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
const schedule = await prisma.schedule.findUnique({ where: { id: block.scheduleId } });
const type: ScheduleType | null =
schedule && isScheduleType(schedule.type) ? schedule.type : null;
const day = normaliseDay(type, input.day);
const checked = await ensureValid(licenseKeyId, day, input, blockId);
if (!checked.ok) return { ok: false, error: checked.error };
const proclamationIds = uniqueIds(input.proclamationIds);
await prisma.block.update({
where: { id: blockId },
data: {
day,
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: dynamicAudioOrDefault(input),
proclamations: {
deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: blockId };
}
export async function deleteBlock(
licenseKeyId: string,
blockId: string
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { licenseKeyId } },
select: { id: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
await prisma.block.delete({ where: { id: blockId } });
return { ok: true, id: blockId };
}
/** Validate a whole schedule payload (used by tests and the endpoint). */
export function validateScheduleBlocks(
type: ScheduleType,
blocks: ValidationBlock[]
): ReturnType<typeof validateSchedule> {
return validateSchedule(type, blocks);
}
/** Flatten a loaded Schedule's Blocks (with Proclamations) for serialization. */
export function scheduleBlocksToInput(
blocks: {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { proclamation: { id: string } }[];
}[]
) {
return blocks.map((block) => ({
id: block.id,
day: block.day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamationIds: block.proclamations.map((entry) => entry.proclamation.id)
}));
}
// ─── Copy a Schedule between License Keys ──────────────────────────────
export type CopyScheduleResult =
| { ok: true; copied: number; targets: { id: string; label: string }[] }
| { ok: false; error: string };
/** A License Key row as needed for ownership checks and labels. */
export type OwnedLicense = { id: string; systemName: string | null; key: string };
function licenseLabel(license: { systemName: string | null; key: string }): string {
return license.systemName ?? license.key;
}
/**
* Copy one License Key's Schedule to one or more other License Keys, by value,
* in a single transaction: all targets succeed or none does. Targets get the
* source's `type` and fresh Block IDs (so every target's content-derived
* `version`/ETag changes and its Kiosk refetches). `dynamicAudio` and
* Proclamation references are preserved.
*
* `ownedLicenses` must be the caller's account License Keys; the source and
* every target are checked against it. The source must have a Schedule with a
* non-null type. Each resulting Schedule is validated before commit.
*/
export async function copySchedule(
ownedLicenses: OwnedLicense[],
sourceId: string,
targetIds: string[]
): Promise<CopyScheduleResult> {
const byId = new Map(ownedLicenses.map((l) => [l.id, l]));
const source = byId.get(sourceId);
if (!source) return { ok: false, error: 'Source system not found.' };
const uniqueTargets = [...new Set(targetIds)].filter((id) => id.length > 0);
if (uniqueTargets.length === 0) {
return { ok: false, error: 'Pick at least one system to copy to.' };
}
if (uniqueTargets.some((id) => id === sourceId)) {
return { ok: false, error: "A schedule can't be copied onto its own system." };
}
for (const id of uniqueTargets) {
if (!byId.has(id)) return { ok: false, error: 'Target system not found.' };
}
const sourceSchedule = await prisma.schedule.findUnique({
where: { licenseKeyId: sourceId },
include: { blocks: { include: blockInclude } }
});
const sourceType: ScheduleType | null =
sourceSchedule && isScheduleType(sourceSchedule.type) ? sourceSchedule.type : null;
if (!sourceSchedule || sourceType === null) {
return { ok: false, error: 'The source system has no schedule to copy.' };
}
// Snapshot the source content (order preserved).
const sourceBlocks = sourceSchedule.blocks.map((block) => ({
day: block.day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamationIds: block.proclamations
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((entry) => entry.proclamation.id)
}));
// Validate the resulting schedule once (it is identical for every target).
const validation = validateSchedule(
sourceType,
sourceBlocks.map((b) =>
toValidationBlock({
day: b.day,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
proclamationIds: b.proclamationIds
})
)
);
if (!validation.ok) {
return { ok: false, error: `The source schedule is invalid: ${validation.error}` };
}
try {
await prisma.$transaction(async (tx) => {
for (const targetId of uniqueTargets) {
// Upsert the target Schedule, replacing its type.
const existing = await tx.schedule.findUnique({ where: { licenseKeyId: targetId } });
let scheduleId: string;
if (existing) {
await tx.block.deleteMany({ where: { scheduleId: existing.id } });
await tx.schedule.update({
where: { id: existing.id },
data: { type: sourceType }
});
scheduleId = existing.id;
} else {
const created = await tx.schedule.create({
data: { licenseKeyId: targetId, type: sourceType }
});
scheduleId = created.id;
}
// New Block IDs per target; Proclamation refs + dynamicAudio kept.
for (const block of sourceBlocks) {
await tx.block.create({
data: {
scheduleId,
day: block.day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: {
create: block.proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
}
}
});
} catch {
return { ok: false, error: 'Copy failed; no systems were changed.' };
}
return {
ok: true,
copied: uniqueTargets.length,
targets: uniqueTargets.map((id) => {
const license = byId.get(id) as OwnedLicense;
return { id, label: licenseLabel(license) };
})
};
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findFirst: vi.fn(), update: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { applySystemName, normalizeSystemName, SYSTEM_NAME_MAX_LENGTH } from './system-name';
const keyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const keyUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('normalizeSystemName', () => {
it('trims and returns a case-folded copy', () => {
expect(normalizeSystemName(' Living Room ')).toEqual({
ok: true,
name: 'Living Room',
lower: 'living room'
});
});
it('accepts the length bounds', () => {
expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH)).ok).toBe(true);
expect(normalizeSystemName('a')).toEqual({ ok: true, name: 'a', lower: 'a' });
});
it('rejects empty, whitespace, and out-of-range values', () => {
expect(normalizeSystemName('')).toEqual({ ok: false });
expect(normalizeSystemName(' ')).toEqual({ ok: false });
expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH + 1))).toEqual({ ok: false });
});
it('rejects non-strings', () => {
expect(normalizeSystemName(undefined)).toEqual({ ok: false });
expect(normalizeSystemName(null)).toEqual({ ok: false });
expect(normalizeSystemName(42)).toEqual({ ok: false });
});
});
describe('applySystemName', () => {
it('reports unchanged without writing when identical', async () => {
const status = await applySystemName(
{ id: 'k1', userId: 'u1', systemName: 'Living Room' },
'Living Room',
'living room'
);
expect(status).toBe('unchanged');
expect(keyFindFirst).not.toHaveBeenCalled();
expect(keyUpdate).not.toHaveBeenCalled();
});
it('reports conflict when another license on the account holds the name', async () => {
keyFindFirst.mockResolvedValue({ id: 'other' });
const status = await applySystemName(
{ id: 'k1', userId: 'u1', systemName: null },
'Den',
'den'
);
expect(status).toBe('conflict');
expect(keyUpdate).not.toHaveBeenCalled();
});
it('writes the name and its case-folded copy when free', async () => {
keyFindFirst.mockResolvedValue(null);
keyUpdate.mockResolvedValue({});
const status = await applySystemName(
{ id: 'k1', userId: 'u1', systemName: null },
'Den',
'den'
);
expect(status).toBe('ok');
expect(keyUpdate).toHaveBeenCalledWith({
where: { id: 'k1' },
data: { systemName: 'Den', systemNameLower: 'den' }
});
});
it('skips the uniqueness check for a license with no account', async () => {
keyUpdate.mockResolvedValue({});
const status = await applySystemName(
{ id: 'k1', userId: null, systemName: null },
'Den',
'den'
);
expect(status).toBe('ok');
expect(keyFindFirst).not.toHaveBeenCalled();
expect(keyUpdate).toHaveBeenCalled();
});
it('maps a unique-constraint violation to conflict', async () => {
keyFindFirst.mockResolvedValue(null);
keyUpdate.mockRejectedValue({ code: 'P2002' });
const status = await applySystemName(
{ id: 'k1', userId: 'u1', systemName: null },
'Den',
'den'
);
expect(status).toBe('conflict');
});
});
+73
View File
@@ -0,0 +1,73 @@
// System Name helpers. A System Name is a human-readable label for a License
// Key, set only by the Kiosk and displayed read-only by the Portal. It is never
// a reference or foreign key. Uniqueness is per account (the owning User) and
// case-insensitive when non-null, enforced by the unique index on
// (userId, systemNameLower).
import { prisma } from '$lib/db';
export const SYSTEM_NAME_MAX_LENGTH = 40;
export type SystemNameStatus = 'ok' | 'unchanged' | 'conflict' | 'invalid';
export type NormalizedName = { ok: true; name: string; lower: string } | { ok: false };
/** Trim and validate a caller-supplied System Name. Stored as typed (trimmed);
* `lower` is the case-folded copy used for uniqueness. */
export function normalizeSystemName(raw: unknown): NormalizedName {
if (typeof raw !== 'string') return { ok: false };
const name = raw.trim();
if (name.length < 1 || name.length > SYSTEM_NAME_MAX_LENGTH) return { ok: false };
return { ok: true, name, lower: name.toLowerCase() };
}
type SystemNameLicense = {
id: string;
userId: string | null;
systemName: string | null;
};
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'P2002'
);
}
/**
* Apply a normalized name to a license. Returns the resulting status:
* - `unchanged` when the stored name is already identical
* - `conflict` when another license on the same account holds the name
* - `ok` when the name was written
* A license with no owning account (legacy, userId null) is written without a
* uniqueness check; per-account uniqueness cannot apply without an account.
*/
export async function applySystemName(
license: SystemNameLicense,
name: string,
lower: string
): Promise<SystemNameStatus> {
if (license.systemName === name) return 'unchanged';
if (license.userId) {
const conflict = await prisma.licenseKey.findFirst({
where: {
userId: license.userId,
systemNameLower: lower,
id: { not: license.id }
},
select: { id: true }
});
if (conflict) return 'conflict';
}
try {
await prisma.licenseKey.update({
where: { id: license.id },
data: { systemName: name, systemNameLower: lower }
});
return 'ok';
} catch (error) {
if (isUniqueViolation(error)) return 'conflict';
throw error;
}
}
+169
View File
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { buildSchedulePayload, type ScheduleBlockInput } from './v2-serializers';
const FIXTURES = resolve(process.cwd(), 'docs/fixtures');
function load(name: string): {
type: 'daily' | 'weekly' | null;
daily: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
}[];
blocks: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
}[];
weekly: Record<
string,
{
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
}[]
>;
} {
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8'));
}
/** Convert a fixture payload back into DB-shaped blocks. */
function fixtureBlocks(name: string): {
type: 'daily' | 'weekly' | null;
blocks: ScheduleBlockInput[];
} {
const fixture = load(name);
const blocks: ScheduleBlockInput[] = [];
if (fixture.type === 'daily') {
for (const b of fixture.daily) {
blocks.push({
id: b.id,
day: null,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamationIds: b.proclamationIds
});
}
} else if (fixture.type === 'weekly') {
for (const [day, list] of Object.entries(fixture.weekly)) {
for (const b of list) {
blocks.push({
id: b.id,
day,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamationIds: b.proclamationIds
});
}
}
}
return { type: fixture.type, blocks };
}
describe('buildSchedulePayload against fixtures', () => {
it('round-trips the daily fixture content field-for-field', () => {
const { type, blocks } = fixtureBlocks('schedule-daily.json');
const payload = buildSchedulePayload(type, blocks);
const fixture = load('schedule-daily.json');
expect(payload.type).toBe('daily');
expect(payload.daily).toEqual(fixture.daily);
expect(payload.blocks).toEqual(fixture.blocks);
expect(payload.weekly).toEqual({});
});
it('populates all seven weekly keys and leaves blocks empty for weekly', () => {
const { type, blocks } = fixtureBlocks('schedule-weekly.json');
const payload = buildSchedulePayload(type, blocks);
const fixture = load('schedule-weekly.json');
expect(payload.type).toBe('weekly');
expect(Object.keys(payload.weekly).sort()).toEqual([
'fri',
'mon',
'sat',
'sun',
'thu',
'tue',
'wed'
]);
expect(payload.weekly.sun).toEqual(fixture.weekly.sun);
expect(payload.weekly.tue).toEqual([]);
expect(payload.blocks).toEqual([]);
expect(payload.daily).toEqual([]);
});
it('produces type null with empty lists for the none fixture', () => {
const payload = buildSchedulePayload(null, []);
expect(payload.type).toBeNull();
expect(payload.daily).toEqual([]);
expect(payload.weekly).toEqual({});
expect(payload.blocks).toEqual([]);
});
it('preserves midnight-crossing and adjacency in the daily flattening', () => {
const pick = (b: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
}) => ({
id: b.id,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
dynamicAudio: b.dynamicAudio,
proclamationIds: b.proclamationIds
});
for (const name of ['schedule-midnight-crossing.json', 'schedule-adjacent.json']) {
const { type, blocks } = fixtureBlocks(name);
const payload = buildSchedulePayload(type, blocks);
const fixture = load(name);
// The fixture lists blocks in file order; the serializer sorts by start
// time. Compare as an order-independent set of the shared fields.
const byId = (list: ReturnType<typeof pick>[]) =>
[...list].sort((a, b) => a.id.localeCompare(b.id)).map(pick);
expect(byId(payload.blocks)).toEqual(byId(fixture.blocks));
}
});
it('preserves the dynamicAudio flag per block', () => {
const { type, blocks } = fixtureBlocks('schedule-dynamic-audio.json');
const payload = buildSchedulePayload(type, blocks);
expect(payload.blocks.map((b) => b.dynamicAudio)).toEqual([true, false]);
});
});
describe('version (ETag)', () => {
it('is stable for identical content', () => {
const { type, blocks } = fixtureBlocks('schedule-daily.json');
expect(buildSchedulePayload(type, blocks).version).toBe(
buildSchedulePayload(type, blocks).version
);
});
it('changes when the schedule changes', () => {
const { type, blocks } = fixtureBlocks('schedule-daily.json');
const a = buildSchedulePayload(type, blocks).version;
const changed = blocks.map((b, i) => (i === 0 ? { ...b, endMinutes: b.endMinutes + 5 } : b));
const b = buildSchedulePayload(type, changed).version;
expect(a).not.toBe(b);
});
it('changes with the type', () => {
const daily = buildSchedulePayload('daily', []).version;
const weekly = buildSchedulePayload('weekly', []).version;
expect(daily).not.toBe(weekly);
});
});
+5 -3
View File
@@ -139,14 +139,16 @@ describe('serializeGroup', () => {
}); });
describe('serializeLicense', () => { describe('serializeLicense', () => {
it('returns only key and email_address (levels removed)', () => { it('returns key, email_address, and the advisory systemNameStatus', () => {
const result = serializeLicense({ const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX', key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com' emailAddress: 'user@example.com',
systemNameStatus: 'unchanged'
}); });
expect(result).toEqual({ expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX', key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com' email_address: 'user@example.com',
systemNameStatus: 'unchanged'
}); });
expect(Object.keys(result)).not.toContain('subscription_level'); expect(Object.keys(result)).not.toContain('subscription_level');
}); });
+120 -3
View File
@@ -1,3 +1,6 @@
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
import { createHash } from 'node:crypto';
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()` // Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these // map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
// canonical ids (1-9), never the portal DB autoincrement ids, because // canonical ids (1-9), never the portal DB autoincrement ids, because
@@ -117,13 +120,20 @@ export function serializeGroup(g: { id: number; name: string; sortOrder: number
export type V2License = { export type V2License = {
key: string; key: string;
email_address: string; email_address: string;
systemNameStatus: string;
}; };
/** Build the POST /license response. */ /** Build the POST /license response. `systemNameStatus` is advisory: the Kiosk
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License { * treats "ok"/"unchanged" as synced and reverts on "conflict"/"invalid". */
export function serializeLicense(opts: {
key: string;
emailAddress: string;
systemNameStatus: string;
}): V2License {
return { return {
key: opts.key, key: opts.key,
email_address: opts.emailAddress email_address: opts.emailAddress,
systemNameStatus: opts.systemNameStatus
}; };
} }
@@ -138,3 +148,110 @@ export function serializeColorPalette(palette: {
}): V2ColorPalette { }): V2ColorPalette {
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors }; return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
} }
export type V2ScheduleBlock = {
id: string;
startTime: string;
startMinutes: number;
endTime: string;
endMinutes: number;
overnight: boolean;
durationMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
};
export type V2Schedule = {
email: string;
blocks: V2ScheduleBlock[];
};
/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are
* returned; the kiosk fetches proclamation content separately. `overnight`
* marks a range that crosses midnight (end clock time before start), and
* `durationMinutes` counts the wrap as the next day. */
export function serializeScheduleBlock(
block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio?: boolean;
},
proclamationIds: string[]
): V2ScheduleBlock {
return {
id: block.id,
startTime: toHm(block.startMinutes),
startMinutes: block.startMinutes,
endTime: toHm(block.endMinutes),
endMinutes: block.endMinutes,
overnight: isOvernight(block),
durationMinutes: blockDuration(block),
dynamicAudio: block.dynamicAudio === true,
proclamationIds
};
}
/** A Block as read from the database, flattened for serialization. */
export type ScheduleBlockInput = {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio?: boolean;
proclamationIds: string[];
};
export type V2ScheduleType = 'daily' | 'weekly' | null;
export type V2SchedulePayload = {
version: string;
type: V2ScheduleType;
daily: V2ScheduleBlock[];
weekly: Record<string, V2ScheduleBlock[]>;
blocks: V2ScheduleBlock[];
};
const WEEK_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function serialise(block: ScheduleBlockInput): V2ScheduleBlock {
return serializeScheduleBlock(block, block.proclamationIds);
}
/**
* Build the target schedule payload. `version` is an opaque content hash that
* doubles as the ETag; it is stable for equal content and changes on any
* mutation. Only the active type's lists are populated. `blocks` is the legacy
* daily flattening (empty for weekly) kept for older Kiosks.
*/
export function buildSchedulePayload(
type: V2ScheduleType,
blocks: ScheduleBlockInput[]
): V2SchedulePayload {
const dailyBlocks: V2ScheduleBlock[] = [];
const weekly: Record<string, V2ScheduleBlock[]> = {};
if (type === 'daily') {
const ordered = [...blocks].sort((a, b) => a.startMinutes - b.startMinutes);
for (const block of ordered) dailyBlocks.push(serialise(block));
} else if (type === 'weekly') {
for (const key of WEEK_KEYS) weekly[key] = [];
const ordered = [...blocks].sort((a, b) => a.startMinutes - b.startMinutes);
for (const block of ordered) {
const key = block.day ?? '';
if (key in weekly) weekly[key].push(serialise(block));
}
}
const version = createHash('sha1')
.update(JSON.stringify({ type, daily: dailyBlocks, weekly }))
.digest('hex');
return {
version,
type,
daily: dailyBlocks,
weekly,
blocks: type === 'daily' ? dailyBlocks : []
};
}
@@ -44,6 +44,7 @@
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400" class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400"
> >
<tr> <tr>
<th class="px-6 py-3 font-semibold">System Name</th>
<th class="px-6 py-3 font-semibold">Key</th> <th class="px-6 py-3 font-semibold">Key</th>
<th class="px-6 py-3 font-semibold">Status</th> <th class="px-6 py-3 font-semibold">Status</th>
<th class="px-6 py-3 font-semibold">Created</th> <th class="px-6 py-3 font-semibold">Created</th>
@@ -53,6 +54,13 @@
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)} {#each data.licenseKeys as key (key.key)}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800"> <tr class="hover:bg-slate-50 dark:hover:bg-slate-800">
<td class="px-6 py-4 text-slate-800 dark:text-slate-200">
{#if key.systemName}
{key.systemName}
{:else}
<span class="text-slate-400 dark:text-slate-500">Unnamed system</span>
{/if}
</td>
<td class="px-6 py-4 font-mono text-slate-800 dark:text-slate-200">{key.key}</td> <td class="px-6 py-4 font-mono text-slate-800 dark:text-slate-200">{key.key}</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
{#if key.active} {#if key.active}
@@ -104,6 +112,9 @@
{/if} {/if}
</span> </span>
</div> </div>
<span class="text-xs text-slate-500 dark:text-slate-400">
{key.systemName ?? 'Unnamed system'}
</span>
<span class="text-xs text-slate-500 dark:text-slate-400" <span class="text-xs text-slate-500 dark:text-slate-400"
>Created {formatDate(key.createdAt)}</span >Created {formatDate(key.createdAt)}</span
> >
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import { clientFormatPhoneNumber } from '$lib/utils'; import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -100,7 +101,7 @@
</div> </div>
{/if} {/if}
<form method="POST" action="?/updateProfile" class="mt-5 flex flex-col gap-4"> <form method="POST" action="?/updateProfile" use:enhance class="mt-5 flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300"> <label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
@@ -206,6 +207,7 @@
<form <form
method="POST" method="POST"
action={editingAddressId ? '?/updateAddress' : '?/addAddress'} action={editingAddressId ? '?/updateAddress' : '?/addAddress'}
use:enhance
class="mt-5 flex flex-col gap-4" class="mt-5 flex flex-col gap-4"
> >
{#if editingAddressId} {#if editingAddressId}
@@ -314,8 +316,10 @@
<form <form
method="POST" method="POST"
action="?/deleteAddress" action="?/deleteAddress"
onsubmit={(event) => { use:enhance={({ cancel }) => {
if (!confirm('Delete this address?')) event.preventDefault(); if (!confirm('Delete this address?')) {
cancel();
}
}} }}
> >
<input type="hidden" name="id" value={address.id} /> <input type="hidden" name="id" value={address.id} />
@@ -11,6 +11,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import Window from '$lib/components/containers/Window.svelte'; import Window from '$lib/components/containers/Window.svelte';
import { clientFormatPhoneNumber } from '$lib/utils'; import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -42,7 +43,7 @@
</script> </script>
<Window> <Window>
<form method="POST" class="flex flex-col items-center gap-5"> <form method="POST" use:enhance class="flex flex-col items-center gap-5">
<h1 class="text-center text-2xl font-bold text-slate-900 dark:text-slate-100"> <h1 class="text-center text-2xl font-bold text-slate-900 dark:text-slate-100">
Please finish setting up your profile Please finish setting up your profile
</h1> </h1>
@@ -0,0 +1,216 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import {
copySchedule,
createBlock,
deleteBlock,
getScheduleForLicense,
setScheduleType,
updateBlock
} from '$lib/server/schedule';
const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function parseInput(formData: FormData) {
const startMinutes = Number(formData.get('startMinutes'));
const endMinutes = Number(formData.get('endMinutes'));
const dayRaw = formData.get('day')?.toString() ?? '';
const day = WEEK_DAYS.includes(dayRaw) ? dayRaw : null;
let proclamationIds: string[] = [];
const raw = formData.get('proclamationIds');
if (typeof raw === 'string' && raw.length > 0) {
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
proclamationIds = parsed.filter((id): id is string => typeof id === 'string');
}
} catch {
proclamationIds = [];
}
}
const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { day, startMinutes, endMinutes, proclamationIds, dynamicAudio };
}
/** Ensure the given License Key belongs to the signed-in account. */
async function ownedLicense(userId: string, licenseKeyId: string) {
const license = await prisma.licenseKey.findFirst({
where: { id: licenseKeyId, userId },
select: { id: true, key: true, systemName: true }
});
return license;
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const [licenses, categories, groups, proclamations] = await Promise.all([
prisma.licenseKey.findMany({
where: { userId: locals.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, key: true, systemName: true, active: true }
}),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
// Content is deliberately never selected: the portal only surfaces
// title/category/group.
prisma.proclamation.findMany({
select: { id: true, name: true, categoryId: true, groupId: true },
orderBy: { name: 'asc' }
})
]);
// Which License Keys already have a (typed) Schedule — used by the Copy UI
// to warn before overwriting a target.
const typedSchedules = await prisma.schedule.findMany({
where: { licenseKeyId: { in: licenses.map((l) => l.id) }, type: { not: null } },
select: { licenseKeyId: true }
});
const scheduledIds = new Set(typedSchedules.map((s) => s.licenseKeyId));
const licenseViews = licenses.map((l) => ({
id: l.id,
key: l.key,
systemName: l.systemName,
active: l.active,
hasSchedule: scheduledIds.has(l.id)
}));
const requested = url.searchParams.get('license');
const selected = licenseViews.find((l) => l.id === requested) ?? licenseViews[0] ?? null;
let type: 'daily' | 'weekly' | null = null;
let blocks: {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
}[] = [];
if (selected) {
const schedule = await getScheduleForLicense(selected.id);
if (schedule) {
type = schedule.type === 'daily' || schedule.type === 'weekly' ? schedule.type : null;
blocks = schedule.blocks.map((block) => ({
id: block.id,
day: block.day,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
}
}
return {
licenses: licenseViews,
selectedLicenseId: selected?.id ?? null,
type,
blocks,
categories,
groups,
proclamations
};
};
export const actions: Actions = {
setType: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const rawType = formData.get('type')?.toString() ?? '';
const type = rawType === 'daily' || rawType === 'weekly' ? rawType : null;
const confirm = formData.get('confirm') === 'true';
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
const result = await setScheduleType(licenseKeyId, type, { confirmed: confirm });
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
createBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
const result = await createBlock(licenseKeyId, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
updateBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const id = formData.get('id')?.toString();
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
if (!id) return fail(400, { error: 'Block not found.' });
const result = await updateBlock(licenseKeyId, id, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
deleteBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
const id = formData.get('id')?.toString();
const license = await ownedLicense(locals.user.id, licenseKeyId);
if (!license) return fail(404, { error: 'System not found.' });
if (!id) return fail(400, { error: 'Block not found.' });
const result = await deleteBlock(licenseKeyId, id);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
copy: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const sourceId = formData.get('sourceLicenseKeyId')?.toString() ?? '';
const targetIds = formData
.getAll('targetIds')
.map((v) => v.toString())
.filter((id) => id.length > 0);
// Only the caller's own systems are eligible; ownership is enforced by
// scoping the lookup to the session user.
const owned = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
select: { id: true, systemName: true, key: true }
});
if (!owned.some((l) => l.id === sourceId)) {
return fail(404, { error: 'System not found.' });
}
const result = await copySchedule(owned, sourceId, targetIds);
if (!result.ok) return fail(400, { error: result.error });
return {
success: true,
action: 'copy',
copied: result.copied,
targets: result.targets
};
}
};
@@ -0,0 +1,515 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
import WeekSchedule from '$lib/components/schedule/WeekSchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props();
const WEEK_DAYS: { key: string; label: string; short: string }[] = [
{ key: 'mon', label: 'Monday', short: 'Mon' },
{ key: 'tue', label: 'Tuesday', short: 'Tue' },
{ key: 'wed', label: 'Wednesday', short: 'Wed' },
{ key: 'thu', label: 'Thursday', short: 'Thu' },
{ key: 'fri', label: 'Friday', short: 'Fri' },
{ key: 'sat', label: 'Saturday', short: 'Sat' },
{ key: 'sun', label: 'Sunday', short: 'Sun' }
];
type BlockView = {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
};
// Render straight from the server data. Every action invalidates and reloads
// it, so the view stays in sync without a local mirror that can go stale.
const type = $derived(data.type);
const blocks: BlockView[] = $derived(data.blocks);
let typePending = $state<'daily' | 'weekly' | null>(null);
let showTypeConfirm = $state(false);
let successMessage = $state('');
let errorMessage = $state('');
let modalOpen = $state(false);
let modalMode = $state<'create' | 'edit'>('create');
let modalDay = $state<string | null>(null);
let editingId = $state<string | undefined>(undefined);
let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(true);
let closeTimer: ReturnType<typeof setTimeout> | undefined;
// Hidden forms submit the ?/setType and ?/copy actions with use:enhance so the
// result comes back as a proper ActionResult (no full-page reload).
let typeForm: HTMLFormElement | undefined = $state();
let copyForm: HTMLFormElement | undefined = $state();
// Copy-to dialog state.
let copyOpen = $state(false);
let copyTargets = $state<string[]>([]);
let copyError = $state('');
const copyCandidates = $derived(data.licenses.filter((l) => l.id !== data.selectedLicenseId));
const overwritingTargets = $derived(
copyCandidates.filter((l) => copyTargets.includes(l.id) && l.hasSchedule)
);
const needsCopyConfirm = $derived(overwritingTargets.length > 0);
const selectedLicense = $derived(
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
);
const licenseLabel = $derived(
selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : 'No systems'
);
const dailyBlocks = $derived(blocks.filter((b) => b.day === null));
const hasBlocks = $derived(blocks.length > 0);
function flash(message: string) {
successMessage = message;
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
}
function selectSystem(licenseKeyId: string) {
const url = new URL(window.location.href);
if (licenseKeyId) url.searchParams.set('license', licenseKeyId);
else url.searchParams.delete('license');
window.location.href = url.toString();
}
function requestType(next: 'daily' | 'weekly' | null) {
if (next === type) return;
if (hasBlocks && type !== null && next !== null) {
typePending = next;
showTypeConfirm = true;
return;
}
applyType(next);
}
// Submit the hidden ?/setType form. The enhance callback reloads the server
// data (prefilling/clearing Blocks as needed) without a full navigation.
function applyType(next: 'daily' | 'weekly' | null) {
showTypeConfirm = false;
errorMessage = '';
const form = typeForm;
if (!form) return;
(form.querySelector('input[name="type"]') as HTMLInputElement).value = next ?? '';
(form.querySelector('input[name="confirm"]') as HTMLInputElement).value = 'true';
form.requestSubmit();
}
const handleTypeSubmit: SubmitFunction = () => {
return async ({ result, update }) => {
if (result.type === 'failure') {
errorMessage =
(result.data as { error?: string })?.error ?? 'Could not change the schedule type.';
return;
}
// update() applies the action result and reloads all data (it calls
// invalidateAll by default), pulling the new type, blocks and flags.
await update();
};
};
function openCreate(day: string | null, startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
modalDay = day;
initialStart = startMinutes;
initialEnd = endMinutes;
initialIds = [];
initialDynamicAudio = true;
modalOpen = true;
}
function openEdit(block: {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string }[];
}) {
modalMode = 'edit';
editingId = block.id;
modalDay = block.day;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((p) => p.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
function handleSaved() {
modalOpen = false;
flash(modalMode === 'edit' ? 'Block saved.' : 'Block created.');
}
function openCopy() {
copyTargets = [];
copyError = '';
copyOpen = true;
}
function toggleCopyTarget(id: string, checked: boolean) {
copyTargets = checked
? [...new Set([...copyTargets, id])]
: copyTargets.filter((t) => t !== id);
}
function submitCopy() {
copyError = '';
if (copyTargets.length === 0) {
copyError = 'Pick at least one system to copy to.';
return;
}
copyForm?.requestSubmit();
}
const handleCopySubmit: SubmitFunction = () => {
return async ({ result, update }) => {
if (result.type === 'failure') {
copyError = (result.data as { error?: string })?.error ?? 'Copy failed.';
return;
}
if (result.type === 'error') {
copyError = 'Copy failed.';
return;
}
const data =
'data' in result ? (result.data as { targets?: { label: string }[] }) : undefined;
const targets = (data?.targets ?? []).map((t) => t.label);
copyOpen = false;
flash(`Copied to ${targets.join(', ')}.`);
await update();
};
};
</script>
<form
method="POST"
action="?/setType"
use:enhance={handleTypeSubmit}
bind:this={typeForm}
class="hidden"
>
<input type="hidden" name="licenseKeyId" value={data.selectedLicenseId ?? ''} />
<input type="hidden" name="type" value="" />
<input type="hidden" name="confirm" value="false" />
</form>
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build a listening schedule for a system. Up to 4 blocks per day.
</p>
</div>
{#if data.licenses.length > 0}
<label class="flex items-center gap-2 text-sm">
<span class="text-slate-500 dark:text-slate-400">System</span>
<select
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={data.selectedLicenseId ?? ''}
onchange={(e) => selectSystem(e.currentTarget.value)}
>
{#each data.licenses as license (license.id)}
<option value={license.id}>{license.systemName ?? license.key}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if successMessage}
<div
class="mt-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
{#if errorMessage}
<div
class="mt-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"
>
{errorMessage}
</div>
{/if}
{#if !selectedLicense}
<div
class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-sm text-slate-600 dark:text-slate-400">
You don't have any systems yet. A system appears here once a Kiosk validates its License
Key.
</p>
</div>
{:else}
<!-- System + mode -->
<div class="mt-6 flex flex-wrap items-center gap-4">
<div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">System</span>
<p class="text-sm text-slate-500 dark:text-slate-400">
{licenseLabel}
{#if !selectedLicense.systemName}
<span class="text-slate-400 dark:text-slate-500">(unnamed system)</span>
{/if}
</p>
</div>
<div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Schedule type</span>
<div
class="mt-1 inline-flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
>
<button
type="button"
class="px-3 py-1.5 text-sm {type === 'daily'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300'}"
onclick={() => requestType('daily')}>Daily</button
>
<button
type="button"
class="px-3 py-1.5 text-sm {type === 'weekly'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300'}"
onclick={() => requestType('weekly')}>Weekly</button
>
</div>
{#if type !== null}
<button
type="button"
class="ml-2 text-sm text-red-600 hover:underline dark:text-red-400"
onclick={() => requestType(null)}>Delete schedule</button
>
{#if copyCandidates.length > 0}
<button
type="button"
class="ml-3 text-sm text-indigo-600 hover:underline dark:text-indigo-400"
onclick={openCopy}>Copy to&hellip;</button
>
{/if}
{/if}
</div>
</div>
{#if type === null}
<div
class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-sm text-slate-600 dark:text-slate-400">
No schedule. Choose Daily or Weekly to start.
</p>
</div>
<div
class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
>
Times run in the Kiosk's own timezone ({selectedLicense.systemName ?? selectedLicense.key}
sets its own timezone). They are not tied to your browser's timezone.
</div>
{:else if type === 'daily'}
<div class="mt-6">
<WeekSchedule
days={[{ key: 'daily', label: 'Every day' }]}
blocks={dailyBlocks}
onCreate={(_day, s, e) => openCreate(null, s, e)}
onEdit={openEdit}
/>
</div>
{:else}
<div class="mt-6">
<WeekSchedule
days={WEEK_DAYS}
{blocks}
onCreate={(day, s, e) => openCreate(day, s, e)}
onEdit={openEdit}
/>
</div>
{/if}
<p class="mt-4 text-sm text-slate-500 dark:text-slate-400">
Gaps between blocks are fine: the Kiosk plays its locally selected Proclamations during a gap.
Times run in the Kiosk's own timezone ({selectedLicense.systemName ?? selectedLicense.key}),
not your browser's.
</p>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
day={modalDay}
licenseKeyId={data.selectedLicenseId ?? ''}
scheduleType={type}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
{/if}
</div>
{#if showTypeConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4">
<div
class="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">Change schedule type?</h2>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
Switching type replaces the current blocks.
{#if typePending === 'weekly'}
The Daily blocks will be copied to all seven days.
{:else}
The existing blocks will be cleared.
{/if}
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
onclick={() => (showTypeConfirm = false)}>Cancel</button
>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
onclick={() => applyType(typePending)}>Continue</button
>
</div>
</div>
</div>
{/if}
{#if copyOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4">
<div
class="relative z-10 flex max-h-[90vh] w-full max-w-lg 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">
Copy schedule to&hellip;
</h2>
<button
type="button"
aria-label="Close"
onclick={() => (copyOpen = false)}
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<p class="text-sm text-slate-500 dark:text-slate-400">
Copy <span class="font-medium text-slate-700 dark:text-slate-200"
>{selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : ''}</span
>'s schedule to other systems. Copies are independent snapshots.
</p>
{#if copyError}
<div
class="mt-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"
>
{copyError}
</div>
{/if}
<ul class="mt-4 flex flex-col gap-2">
{#each copyCandidates as candidate (candidate.id)}
<li>
<label
class="flex items-start gap-3 rounded-md border border-slate-200 px-3 py-2 text-sm dark:border-slate-800"
>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
checked={copyTargets.includes(candidate.id)}
onchange={(e) => toggleCopyTarget(candidate.id, e.currentTarget.checked)}
/>
<span class="flex flex-col">
<span class="font-medium text-slate-800 dark:text-slate-200">
{candidate.systemName ?? candidate.key}
</span>
{#if !candidate.systemName}
<span class="text-xs text-slate-400 dark:text-slate-500">Unnamed system</span>
{/if}
{#if candidate.hasSchedule}
<span class="text-xs text-amber-700 dark:text-amber-400">
Has a schedule — copying will overwrite it
</span>
{/if}
</span>
</label>
</li>
{/each}
</ul>
{#if needsCopyConfirm}
<div
class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
>
This will overwrite the existing schedule on
{overwritingTargets.map((t) => t.systemName ?? t.key).join(', ')}.
</div>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<button
type="button"
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"
onclick={() => (copyOpen = false)}
>
Cancel
</button>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700 disabled:opacity-50"
disabled={copyTargets.length === 0}
onclick={submitCopy}
>
{needsCopyConfirm ? 'Overwrite & copy' : 'Copy'}
</button>
</div>
<form
method="POST"
action="?/copy"
use:enhance={handleCopySubmit}
bind:this={copyForm}
class="hidden"
>
<input type="hidden" name="sourceLicenseKeyId" value={data.selectedLicenseId ?? ''} />
{#each copyTargets as target (target)}
<input type="hidden" name="targetIds" value={target} />
{/each}
</form>
</div>
</div>
{/if}
@@ -0,0 +1,278 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isActionFailure } from '@sveltejs/kit';
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() },
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() },
schedule: { findMany: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
getScheduleForLicense: vi.fn(),
setScheduleType: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn(),
copySchedule: vi.fn()
}));
import { prisma } from '$lib/db';
import {
copySchedule,
createBlock,
deleteBlock,
getScheduleForLicense,
setScheduleType
} from '$lib/server/schedule';
import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const scheduleFindMany = prisma.schedule.findMany as ReturnType<typeof vi.fn>;
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
const copyScheduleMock = copySchedule as ReturnType<typeof vi.fn>;
const locals = createLocals(mockUser() as never);
beforeEach(() => {
vi.clearAllMocks();
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 2, name: 'Physical' }
]);
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
licenseFindMany.mockResolvedValue([
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
]);
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
scheduleFindMany.mockResolvedValue([]);
getScheduleMock.mockResolvedValue(null);
});
describe('schedule load', () => {
it('rejects unauthenticated requests', async () => {
await expect(
load({ locals: createLocals(), url: new URL('http://x/') } as never)
).rejects.toMatchObject({
status: 403
});
});
it('never selects proclamation content', async () => {
await load({ locals, url: new URL('http://x/') } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
select: { id: true, name: true, categoryId: true, groupId: true }
})
);
});
it('maps blocks to title/category/group only', async () => {
getScheduleMock.mockResolvedValue({
id: 's1',
type: 'daily',
blocks: [
{
id: 'b1',
day: null,
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
}
}
]
}
]
});
const result = (await load({ locals, url: new URL('http://x/') } as never)) as {
type: string | null;
blocks: { id: string; dynamicAudio: boolean; proclamations: Record<string, unknown>[] }[];
};
expect(result.type).toBe('daily');
expect(result.blocks[0].dynamicAudio).toBe(true);
expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1
});
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
});
});
describe('schedule actions', () => {
it('createBlock rejects unauthenticated requests', async () => {
await expect(
actions.createBlock!({
request: createFormRequest({}),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('createBlock surfaces the service error', async () => {
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
const result = await actions.createBlock!({
request: createFormRequest({
licenseKeyId: 'lk1',
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.status).toBe(400);
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
}
});
it('createBlock parses the payload and succeeds', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
const result = await actions.createBlock!({
request: createFormRequest({
licenseKeyId: 'lk1',
day: 'mon',
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']),
dynamicAudio: 'true'
}),
locals
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('lk1', {
day: 'mon',
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2'],
dynamicAudio: true
});
});
it('setType requiring confirmation returns the confirm error', async () => {
setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' });
const result = await actions.setType!({
request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'confirm' });
}
});
it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({
request: createFormRequest({ licenseKeyId: 'lk1' }),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Block not found.' });
}
expect(deleteBlockMock).not.toHaveBeenCalled();
});
it('copy rejects unauthenticated requests', async () => {
await expect(
actions.copy!({
request: createFormRequest({ sourceLicenseKeyId: 'lk1' }),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('copy rejects a source not owned by the account', async () => {
licenseFindMany.mockResolvedValue([]); // none owned
const result = await actions.copy!({
request: createFormRequest({ sourceLicenseKeyId: 'lk-other' }),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) expect(result.status).toBe(404);
expect(copyScheduleMock).not.toHaveBeenCalled();
});
it('copy passes the owned systems and targets and returns a summary', async () => {
licenseFindMany.mockResolvedValue([
{ id: 'lk1', systemName: 'Living Room', key: 'AAAA-BBBB-CCCC-DDDD' },
{ id: 'lk2', systemName: 'Kitchen', key: 'EEEE-FFFF-GGGG-HHHH' }
]);
copyScheduleMock.mockResolvedValue({
ok: true,
copied: 1,
targets: [{ id: 'lk2', label: 'Kitchen' }]
});
const form = new FormData();
form.append('sourceLicenseKeyId', 'lk1');
form.append('targetIds', 'lk2');
const request = new Request('http://localhost', { method: 'POST', body: form });
const result = await actions.copy!({ request, locals } as never);
expect(result).toEqual({
success: true,
action: 'copy',
copied: 1,
targets: [{ id: 'lk2', label: 'Kitchen' }]
});
expect(copyScheduleMock).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: 'lk1' }),
expect.objectContaining({ id: 'lk2' })
]),
'lk1',
['lk2']
);
});
it('copy surfaces a service error', async () => {
copyScheduleMock.mockResolvedValue({ ok: false, error: 'Target system not found.' });
const form = new FormData();
form.append('sourceLicenseKeyId', 'lk1');
form.append('targetIds', 'lk-bad');
const request = new Request('http://localhost', { method: 'POST', body: form });
const result = await actions.copy!({ request, locals } as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Target system not found.' });
}
});
});
@@ -0,0 +1,67 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import type { PageProps } from './$types';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
import Page from './+page.svelte';
const LICENSE = {
id: 'lk1',
key: 'AAAA-BBBB-CCCC-DDDD',
systemName: 'Living Room',
active: true,
hasSchedule: false
};
const block = {
id: 'b1',
day: null as string | null,
startMinutes: 6 * 60,
endMinutes: 8 * 60,
dynamicAudio: true,
proclamations: [{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 }]
};
function makeData(overrides: Partial<PageProps['data']> = {}): PageProps['data'] {
return {
userRoleId: null,
licenses: [LICENSE],
selectedLicenseId: LICENSE.id,
type: null,
blocks: [],
categories: [{ id: 1, name: 'Physical' }],
groups: [{ id: 1, name: 'General' }],
proclamations: [{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 }],
...overrides
};
}
function mountPage(data: PageProps['data']) {
return render(Page, { params: {}, data, form: null });
}
describe('schedule page', () => {
it('renders a schedule loaded for the selected system (same system, new data)', async () => {
const result = mountPage(makeData());
await expect
.element(page.getByText('No schedule. Choose Daily or Weekly to start.'))
.toBeInTheDocument();
// The server reloads `data` after an action while the selected system id
// stays the same; the view must follow the new data rather than a stale
// local mirror.
await result.rerender({ data: makeData({ type: 'daily', blocks: [block] }) });
await expect.element(page.getByText('Every day')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
await expect
.element(page.getByText('No schedule. Choose Daily or Weekly to start.'))
.not.toBeInTheDocument();
});
});
@@ -14,6 +14,7 @@
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ label: 'Key', value: (r) => r.key }, { label: 'Key', value: (r) => r.key },
{ label: 'System Name', value: (r) => (r.systemName as string | null) ?? 'Unnamed system' },
{ {
label: 'User', label: 'User',
value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? '' value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? ''
+13
View File
@@ -129,6 +129,12 @@
> >
Dashboard Dashboard
</a> </a>
<a
href={resolve('/schedule')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a <a
href={resolve('/profile')} href={resolve('/profile')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`} class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`}
@@ -383,6 +389,13 @@
> >
Dashboard Dashboard
</a> </a>
<a
href={resolve('/schedule')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a <a
href={resolve('/profile')} href={resolve('/profile')}
onclick={closeMenu} onclick={closeMenu}
+21 -1
View File
@@ -4,6 +4,11 @@ import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers'; import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit'; import { rateLimit } from '$lib/server/v2-rate-limit';
import { serializeLicense } from '$lib/server/v2-serializers'; import { serializeLicense } from '$lib/server/v2-serializers';
import {
applySystemName,
normalizeSystemName,
type SystemNameStatus
} from '$lib/server/system-name';
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/; const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
@@ -50,6 +55,20 @@ export const POST: RequestHandler = async ({ request }) => {
throw error(400, 'key does not belong to specified user'); throw error(400, 'key does not belong to specified user');
} }
// Optional System Name (Kiosk is the only writer). Validation always
// succeeds; a non-ok name status is advisory. An omitted field never
// changes or clears the stored name; empty/whitespace is invalid, never a
// clear.
let systemNameStatus: SystemNameStatus = 'unchanged';
if ('systemName' in body) {
const parsed = normalizeSystemName(body.systemName);
if (!parsed.ok) {
systemNameStatus = 'invalid';
} else {
systemNameStatus = await applySystemName(storedKey, parsed.name, parsed.lower);
}
}
// Record that this active key was validated. A failure to record the // Record that this active key was validated. A failure to record the
// timestamp should not fail an otherwise valid validation. // timestamp should not fail an otherwise valid validation.
try { try {
@@ -64,7 +83,8 @@ export const POST: RequestHandler = async ({ request }) => {
return jsonOk( return jsonOk(
serializeLicense({ serializeLicense({
key: storedKey.key, key: storedKey.key,
emailAddress: email emailAddress: email,
systemNameStatus
}) })
); );
}; };
+91 -2
View File
@@ -43,7 +43,8 @@ const storedKey = {
id: 'key-1', id: 'key-1',
key: 'ABCD-EFGH-IJKL-MNOP', key: 'ABCD-EFGH-IJKL-MNOP',
active: true, active: true,
userId: 'user-1' userId: 'user-1',
systemName: null
}; };
describe('POST /api/v2/license', () => { describe('POST /api/v2/license', () => {
@@ -64,7 +65,8 @@ describe('POST /api/v2/license', () => {
const body = (await res.json()) as Record<string, unknown>; const body = (await res.json()) as Record<string, unknown>;
expect(body).toEqual({ expect(body).toEqual({
key: storedKey.key, key: storedKey.key,
email_address: 'user@example.com' email_address: 'user@example.com',
systemNameStatus: 'unchanged'
}); });
expect(Object.keys(body)).not.toContain('subscription_level'); expect(Object.keys(body)).not.toContain('subscription_level');
expect(mockKeyUpdate).toHaveBeenCalledWith({ expect(mockKeyUpdate).toHaveBeenCalledWith({
@@ -147,3 +149,90 @@ describe('POST /api/v2/license', () => {
expect(mockKeyFindFirst).not.toHaveBeenCalled(); expect(mockKeyFindFirst).not.toHaveBeenCalled();
}); });
}); });
describe('POST /api/v2/license systemName handling', () => {
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockKeyUpdate.mockResolvedValue({});
});
async function post(...args: unknown[]) {
const body: Record<string, unknown> = {
license_key: storedKey.key,
email_address: 'user@example.com'
};
if (args.length > 0) body.systemName = args[0];
const res = await POST({ request: licenseRequest(body) } as POSTEvent);
return res;
}
it('leaves the name untouched and reports unchanged when omitted', async () => {
const res = await post();
expect((await res.json()).systemNameStatus).toBe('unchanged');
expect(mockKeyUpdate).toHaveBeenCalledTimes(1);
expect(mockKeyUpdate).toHaveBeenCalledWith({
where: { id: storedKey.id },
data: { lastValidatedAt: expect.any(Date) }
});
});
it('sets a trimmed name, case-folded for uniqueness (ok)', async () => {
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null);
const res = await post(' Living Room ');
expect((await res.json()).systemNameStatus).toBe('ok');
expect(mockKeyUpdate).toHaveBeenCalledWith({
where: { id: storedKey.id },
data: { systemName: 'Living Room', systemNameLower: 'living room' }
});
});
it('reports conflict without writing when another key holds the name', async () => {
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
const res = await post('Living Room');
expect((await res.json()).systemNameStatus).toBe('conflict');
expect(mockKeyUpdate).toHaveBeenCalledTimes(1);
});
it('treats differing casing as a conflict', async () => {
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
const res = await post('living room');
expect((await res.json()).systemNameStatus).toBe('conflict');
});
it('allows the same name in a different account', async () => {
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null);
const res = await post('Living Room');
expect((await res.json()).systemNameStatus).toBe('ok');
});
it('reports invalid without writing for empty, whitespace, too long, or non-string', async () => {
for (const bad of ['', ' ', 'a'.repeat(41), 42]) {
const res = await post(bad);
expect((await res.json()).systemNameStatus).toBe('invalid');
}
// Only the lastValidatedAt stamp is written; no name writes.
for (const call of mockKeyUpdate.mock.calls) {
expect(call[0].data).toEqual({ lastValidatedAt: expect.any(Date) });
}
});
it('validation still succeeds (200) on conflict', async () => {
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
const res = await post('Living Room');
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,48 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { buildSchedulePayload, type V2ScheduleType } from '$lib/server/v2-serializers';
function scheduleType(value: string | null | undefined): V2ScheduleType {
return value === 'daily' || value === 'weekly' ? value : null;
}
/**
* Kiosk-facing, key-only schedule read. Returns the target payload with an ETag
* (the content-derived `version`); a matching `If-None-Match` yields 304. An
* unknown License Key is 404; a key with no Schedule returns `type: null`.
*/
export const GET: RequestHandler = async ({ request, params }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `license-schedule:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const licenseKey = (params.licenseKey ?? '').trim();
const license = await prisma.licenseKey.findUnique({
where: { key: licenseKey },
select: { id: true }
});
if (!license) {
throw error(404, 'unknown_license');
}
const schedule = await getScheduleForLicense(license.id);
const type = scheduleType(schedule?.type);
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
const payload = buildSchedulePayload(type, blocks);
const etag = `"${payload.version}"`;
if (request.headers.get('if-none-match') === etag) {
return new Response(null, { status: 304, headers: { etag } });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'content-type': 'application/json', etag }
});
};
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findUnique: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
getScheduleForLicense: vi.fn(),
scheduleBlocksToInput: vi.fn()
}));
import { prisma } from '$lib/db';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { GET } from './+server';
type GETEvent = Parameters<typeof GET>[0];
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockGetSchedule = getScheduleForLicense as ReturnType<typeof vi.fn>;
const mockToInput = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
const KEY = 'ABCD-EFGH-IJKL-MNOP';
function event(ifNoneMatch?: string): GETEvent {
const headers = new Headers();
if (ifNoneMatch) headers.set('if-none-match', ifNoneMatch);
return {
request: new Request(`http://localhost/api/v2/licenses/${KEY}/schedule`, { headers }),
params: { licenseKey: KEY }
} as GETEvent;
}
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
});
describe('GET /api/v2/licenses/[licenseKey]/schedule', () => {
it('returns 404 for an unknown license', async () => {
mockFindUnique.mockResolvedValue(null);
await expect(GET(event())).rejects.toSatisfy(
(e: unknown) => isHttpError(e) && e.status === 404 && e.body.message === 'unknown_license'
);
});
it('returns type null with empty lists when there is no schedule', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue(null);
const res = await GET(event());
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.type).toBeNull();
expect(body.daily).toEqual([]);
expect(body.blocks).toEqual([]);
expect(body.version).toBeTruthy();
expect(res.headers.get('etag')).toBe(`"${body.version}"`);
});
it('returns the daily payload with an ETag', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue({ type: 'daily' });
mockToInput.mockReturnValue([
{
id: 'b1',
day: null,
startMinutes: 360,
endMinutes: 420,
dynamicAudio: true,
proclamationIds: ['p1']
}
]);
const res = await GET(event());
expect(res.status).toBe(200);
const body = (await res.json()) as { type: string; daily: unknown[]; blocks: unknown[] };
expect(body.type).toBe('daily');
expect(body.daily).toHaveLength(1);
expect(body.blocks).toHaveLength(1);
});
it('returns 304 when If-None-Match matches the ETag', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue(null);
const first = await GET(event());
const etag = first.headers.get('etag') as string;
const second = await GET(event(etag));
expect(second.status).toBe(304);
expect(second.headers.get('etag')).toBe(etag);
});
it('returns 200 when If-None-Match does not match', async () => {
mockFindUnique.mockResolvedValue({ id: 'lk1' });
mockGetSchedule.mockResolvedValue(null);
const res = await GET(event('"stale"'));
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,40 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { applySystemName, normalizeSystemName } from '$lib/server/system-name';
/**
* Kiosk-facing. Sets the System Name for a License Key. The Kiosk is the only
* writer; the Portal never calls this. Idempotent: re-sending the current name
* returns 200 without a write. Responds 400 `invalid_name`, 404
* `unknown_license`, or 409 `name_taken`.
*/
export const PUT: RequestHandler = async ({ request, params }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `system-name:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const licenseKey = (params.licenseKey ?? '').trim();
const body = (await readJsonBody(request)) as Record<string, unknown>;
const parsed = normalizeSystemName(body.systemName);
if (!parsed.ok) {
throw error(400, 'invalid_name');
}
const license = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
if (!license) {
throw error(404, 'unknown_license');
}
const status = await applySystemName(license, parsed.name, parsed.lower);
if (status === 'conflict') {
throw error(409, 'name_taken');
}
return jsonOk({ systemName: parsed.name, systemNameStatus: 'ok' });
};
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { PUT } from './+server';
type PUTEvent = Parameters<typeof PUT>[0];
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const mockUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
const KEY = 'ABCD-EFGH-IJKL-MNOP';
const storedKey = {
id: 'key-1',
key: KEY,
active: true,
userId: 'user-1',
systemName: null as string | null
};
function putRequest(body: unknown): Request {
return new Request(`http://localhost/api/v2/licenses/${KEY}/system-name`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
}
function event(body: unknown): PUTEvent {
return { request: putRequest(body), params: { licenseKey: KEY } } as PUTEvent;
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
});
describe('PUT /api/v2/licenses/[licenseKey]/system-name', () => {
it('sets the name and returns it', async () => {
mockFindUnique.mockResolvedValue(storedKey);
mockFindFirst.mockResolvedValue(null);
mockUpdate.mockResolvedValue({});
const res = await PUT(event({ systemName: ' Living Room ' }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' });
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: 'key-1' },
data: { systemName: 'Living Room', systemNameLower: 'living room' }
});
});
it('is idempotent when the name is unchanged', async () => {
mockFindUnique.mockResolvedValue({ ...storedKey, systemName: 'Living Room' });
const res = await PUT(event({ systemName: 'Living Room' }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' });
expect(mockUpdate).not.toHaveBeenCalled();
});
it('rejects an invalid name', async () => {
await expectHttpError(PUT(event({ systemName: ' ' })), 400, 'invalid_name');
await expectHttpError(PUT(event({ systemName: '' })), 400, 'invalid_name');
await expectHttpError(PUT(event({ systemName: 'a'.repeat(41) })), 400, 'invalid_name');
await expectHttpError(PUT(event({})), 400, 'invalid_name');
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('returns 404 for an unknown license', async () => {
mockFindUnique.mockResolvedValue(null);
await expectHttpError(PUT(event({ systemName: 'Den' })), 404, 'unknown_license');
expect(mockUpdate).not.toHaveBeenCalled();
});
it('returns 409 when another license on the account holds the name', async () => {
mockFindUnique.mockResolvedValue(storedKey);
mockFindFirst.mockResolvedValue({ id: 'other' });
await expectHttpError(PUT(event({ systemName: 'Den' })), 409, 'name_taken');
expect(mockUpdate).not.toHaveBeenCalled();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { buildSchedulePayload } from '$lib/server/v2-serializers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function scheduleType(value: string | null | undefined) {
return value === 'daily' || value === 'weekly' ? value : null;
}
/**
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
* `email` + `licenseKey` query parameters and validates only that the pair
* belong together — license active/entitlement is deliberately not checked
* here, since an invalid license would already lock the kiosk out.
*
* Extended additively with `version`, `type`, `daily`, and `weekly`. `email`
* and `blocks` keep their exact previous meaning (daily flattening; empty for
* weekly), so old Kiosks are unaffected. No ETag/304 here.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `schedule:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const email = (url.searchParams.get('email') ?? '').trim().toLowerCase();
const licenseKey = (
url.searchParams.get('licenseKey') ??
url.searchParams.get('key') ??
''
).trim();
if (!email || !licenseKey || email.length > 254 || !EMAIL_RE.test(email)) {
throw error(400, 'not-authorized');
}
const storedKey = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
if (!storedKey) {
throw error(400, 'not-authorized');
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
throw error(400, 'not-authorized');
}
// Licenses created before accounts were linked may carry only an email.
const belongs =
storedKey.userId === user.id ||
(storedKey.userId === null && storedKey.email?.toLowerCase() === email);
if (!belongs) {
throw error(400, "this key doesn't appear to belong to you");
}
const schedule = await getScheduleForLicense(storedKey.id);
const type = scheduleType(schedule?.type);
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
const payload = buildSchedulePayload(type, blocks);
return jsonOk({ email, ...payload });
};
+196
View File
@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findUnique: vi.fn() },
user: { findUnique: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
getScheduleForLicense: vi.fn(),
scheduleBlocksToInput: vi.fn()
}));
import { prisma } from '$lib/db';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { GET } from './+server';
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
const toInputMock = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
function call(params: Record<string, string>) {
const url = new URL('http://localhost/api/v2/schedule');
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const request = new Request(url);
return GET({ request, url } as Parameters<typeof GET>[0]);
}
const KEY = 'AAAA-BBBB-CCCC-DDDD';
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
getScheduleMock.mockResolvedValue(null);
toInputMock.mockReturnValue([]);
});
describe('GET /api/v2/schedule auth', () => {
it('rejects a request missing parameters', async () => {
await expect(call({})).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects an unknown license key', async () => {
licenseKeyFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects an unknown user', async () => {
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects a key that does not belong to the email account', async () => {
licenseKeyFindUnique.mockResolvedValue({
id: 'lk1',
key: KEY,
userId: 'someone-else',
email: null
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: "this key doesn't appear to belong to you" }
});
});
it('rate limits after ten requests from the same IP', async () => {
licenseKeyFindUnique.mockResolvedValue(null);
for (let i = 0; i < 10; i++) {
try {
await call({});
} catch {
// Expected: each early call rejects with a 400.
}
}
const response = await call({});
expect(response.status).toBe(429);
});
});
describe('GET /api/v2/schedule success', () => {
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
licenseKeyFindUnique.mockResolvedValue({
id: 'lk1',
key: KEY,
userId: 'user-1',
email: null,
active: false
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
getScheduleMock.mockResolvedValue({ type: 'daily' });
toInputMock.mockReturnValue([
{
id: 'block-1',
day: null,
startMinutes: 21 * 60,
endMinutes: 9 * 60,
dynamicAudio: true,
proclamationIds: ['p1', 'p2']
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
expect(response.status).toBe(200);
const body = (await response.json()) as { email: string; blocks: unknown[]; type: string };
expect(body.email).toBe('jane@example.com');
expect(body.blocks).toEqual([
{
id: 'block-1',
startTime: '21:00',
startMinutes: 1260,
endTime: '09:00',
endMinutes: 540,
overnight: true,
durationMinutes: 720,
dynamicAudio: true,
proclamationIds: ['p1', 'p2']
}
]);
// Additive fields for new Kiosks.
expect(body.type).toBe('daily');
expect(body).toHaveProperty('version');
expect(body).toHaveProperty('daily');
expect(body).toHaveProperty('weekly');
});
it('returns IDs for every selected proclamation regardless of frequency', async () => {
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
getScheduleMock.mockResolvedValue({ type: 'daily' });
toInputMock.mockReturnValue([
{
id: 'block-1',
day: null,
startMinutes: 0,
endMinutes: 60,
dynamicAudio: false,
proclamationIds: ['p1']
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
const body = (await response.json()) as {
blocks: { proclamationIds: string[]; overnight: boolean; durationMinutes: number }[];
};
expect(body.blocks[0]).toEqual({
id: 'block-1',
startTime: '00:00',
startMinutes: 0,
endTime: '01:00',
endMinutes: 60,
overnight: false,
durationMinutes: 60,
dynamicAudio: false,
proclamationIds: ['p1']
});
});
it('accepts the legacy `key` query alias and an email-only license', async () => {
licenseKeyFindUnique.mockResolvedValue({
id: 'lk1',
key: KEY,
userId: null,
email: 'jane@example.com'
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
const response = await call({ email: 'jane@example.com', key: KEY });
expect(response.status).toBe(200);
const body = (await response.json()) as { email: string; blocks: unknown[]; type: null };
expect(body.email).toBe('jane@example.com');
expect(body.blocks).toEqual([]);
expect(body.type).toBeNull();
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import Window from '$lib/components/containers/Window.svelte'; import Window from '$lib/components/containers/Window.svelte';
@@ -24,7 +25,7 @@
</div> </div>
{/if} {/if}
<form method="POST" class="flex flex-col gap-4"> <form method="POST" use:enhance class="flex flex-col gap-4">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Forgot Password</h1> <h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Forgot Password</h1>
<p class="text-sm text-slate-600 dark:text-slate-400"> <p class="text-sm text-slate-600 dark:text-slate-400">
Enter your email and we'll send you a link to reset your password. Enter your email and we'll send you a link to reset your password.
+2 -1
View File
@@ -10,7 +10,8 @@
let verified = $derived(page.url.searchParams.get('verified') === 'true'); let verified = $derived(page.url.searchParams.get('verified') === 'true');
async function handleSubmit() { async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const { error: authError } = await authClient.signIn.email({ const { error: authError } = await authClient.signIn.email({
email, email,
password, password,
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import Window from '$lib/components/containers/Window.svelte'; import Window from '$lib/components/containers/Window.svelte';
import { authClient } from '$lib/auth-client'; import { authClient } from '$lib/auth-client';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -32,7 +33,7 @@
</div> </div>
{/if} {/if}
<form method="POST" class="flex flex-col gap-4"> <form method="POST" use:enhance class="flex flex-col gap-4">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Create Account</h1> <h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Create Account</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import Window from '$lib/components/containers/Window.svelte'; import Window from '$lib/components/containers/Window.svelte';
@@ -15,7 +16,7 @@
</div> </div>
{/if} {/if}
<form method="POST" class="flex flex-col gap-4"> <form method="POST" use:enhance class="flex flex-col gap-4">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100"> <h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">
{data.invite ? 'Set Your Password' : 'Reset Password'} {data.invite ? 'Set Your Password' : 'Reset Password'}
</h1> </h1>