Compare commits

..
28 Commits
Author SHA1 Message Date
NeilClack 2aa6669007 Migrate actions to new runner 2026-09-29 23:28:50 -05:00
NeilClack 87934dfd42 chore(vscode): remove debugger configuration 2026-09-29 15:22:03 -05:00
NeilClack 220ff92e78 Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid 2026-09-29 15:22:02 -05:00
NeilClack cc4aa8717e chore(vscode): fix dev-server task matcher and add debuggable-Chrome task
Disable Vite's ANSI output (NO_COLOR/FORCE_COLOR) so the background-task
problem matcher's endsPattern reliably matches, fixing the
'Error exists after running preLaunchTask' failure. Add a
'Launch Debuggable Chrome' task (:9222, persistent ~/.chrome-rw-debug
profile) and an attach compound so you can debug the frontend in a Chrome
you control and stay logged in. Update DEBUGGING.md.
2026-09-29 13:41:18 -05:00
NeilClack e9f94b72ee chore: prettier-format captured Portal fixtures 2026-09-29 13:29:55 -05:00
NeilClack 04e1f79f37 chore(vscode): separate backend and frontend debug configs
Backend configs run Vite directly (not via npm run dev) so the Node
inspector owns the process that executes SSR code, with source maps
enabled for .ts server files. Frontend configs launch/attach Chrome.
Adds a Node-inspector dev task, a Full Stack compound, and DEBUGGING.md
documenting the Safari limitation and workflows.
2026-09-29 13:29:55 -05:00
NeilClack bbf4ac12ee Portal: calendar-style weekly schedule grid, responsive on mobile
Replace the stacked per-day lists with a Google-Calendar-style WeekSchedule:
day columns side by side over a shared, sticky time gutter. Click or drag an
empty slot to create a block at that time; click a block to edit it. The
daily schedule reuses the same grid with a single 'Every day' column so both
modes look consistent. On narrow screens the row scrolls horizontally with
short day labels and the gutter pinned, keeping it usable on phones. Also
change the type switcher and copy dialog to enhanced form submissions (no
full-page reload) so they respond immediately. Removes the now-unused
DaySchedule component.
2026-09-29 13:09:48 -05:00
NeilClack bf465eca60 docs: record Phase 5 verification results 2026-09-29 12:48:26 -05:00
NeilClack 6a3ecaf734 docs: Phase 5 finalize contract, add release notes, sync fixtures 2026-09-29 12:47:33 -05:00
NeilClack e6021a6345 test: Phase 5 verification scripts and opt-in e2e integration spec
Adds scripts to seed scenario data, create Proclamations, snapshot schedules,
drive the Kiosk-facing HTTP endpoints, and an opt-in (P5_INTEGRATION=1)
Vitest spec exercising create/type/copy against a scratch database. The
integration spec is skipped by default so the normal suite needs no DB.
2026-09-29 12:46:04 -05:00
NeilClack be303dcd9c docs: finalize Phase 4 schedule copy in the contract 2026-09-29 10:50:18 -05:00
NeilClack fcb5db458b Portal: Copy to… schedule UI
Adds a Copy to… action on a system's schedule with a multi-select of the
account's other systems, labelled by System Name (fallback: key), excluding
the source. Targets that already have a schedule are flagged and require an
explicit Overwrite & copy confirmation. Shows a summary of the systems
updated after success.
2026-09-29 10:49:15 -05:00
NeilClack 242be5d241 Portal: copy action and hasSchedule flag in the schedule loader
Adds a session-authenticated copy action that scopes the source and targets
to the caller's License Keys and returns a summary of the systems updated.
The loader now reports hasSchedule per system so the Copy UI can warn before
overwriting. Adds action tests (auth, cross-account, summary, error).
2026-09-29 10:49:10 -05:00
NeilClack 68a979f1a1 Portal: transactional schedule copy service
copySchedule copies one License Key's Schedule to one or more other keys by
value in a single Prisma transaction (all targets or none), assigning fresh
Block IDs and preserving each block's dynamicAudio and Proclamation order.
Source/targets are scoped to the caller's account; self-copy and empty
target lists are rejected. The resulting schedule is run through the shared
validator before commit. Adds tests: daily/weekly copy, overwrite, multiple
targets, rollback, cross-account rejection, new IDs, changed version/ETag,
copy independence, and Kiosk-payload parity with the shared fixtures.
2026-09-29 10:49:06 -05:00
NeilClack f6c937b892 docs: document Phase 3 schedule endpoint, model, and resolved open items 2026-09-29 10:35:44 -05:00
NeilClack 9a2888af36 Portal: per-system daily/weekly schedule editor
System picker lists the account's License Keys labelled by System Name
(fallback: key). Schedule type selector (Daily|Weekly) with a confirmation
before switching a populated schedule (daily->weekly prefills all seven
days; otherwise clears). Weekly shows a per-day editor. Blocks use
TimeInput, allow adjacent/midnight-crossing ranges (labelled 'ends next
day'), block empty blocks client-side, default dynamic audio on, and show
title/category/group only. Adds gap helper text and an informational
kiosk-timezone note (browser fallback; no external/paid dependency).
2026-09-29 10:28:05 -05:00
NeilClack 3bb8e0189b Portal: per-license schedule service, shared validator, and key-only GET
Implements the shared validateSchedule validator (daily + weekly, midnight
spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a
per-License-Key schedule service (get/set type, CRUD blocks, weekly
prefill with confirmation), the content-hash version/ETag serializer, the
new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null),
and additive evolution of the legacy GET /api/v2/schedule. Adds validator,
serializer (against docs/fixtures), endpoint ETag/304, and service tests.
2026-09-29 10:23:10 -05:00
NeilClack 138bf03ea9 Portal: move Schedule to per-License-Key with daily/weekly and Block.day
Adds Schedule.licenseKeyId (unique, nullable) and Schedule.type, makes the
legacy userId optional/non-unique, adds Block.day and flips Block
dynamicAudio default to true. The migration backfills each existing
per-user schedule onto that account's oldest License Key as a daily
schedule, preserves unmappable schedules (licenseKeyId NULL) with a
NOTICE, and is reversible. Tested up/down against a scratch schema.
2026-09-29 10:11:21 -05:00
NeilClack 25b5eeb144 docs: document System Name PUT endpoint and validation fields in OpenAPI 2026-09-29 09:59:17 -05:00
NeilClack 3c49f425d8 Portal: display System Name read-only on dashboard and admin lists
Show the System Name where license keys are listed, falling back to an
'Unnamed system' indication when unset. No rename UI or mutation path.
2026-09-29 09:51:58 -05:00
NeilClack 8412dfb69c Portal: System Name PUT endpoint and license-validation status
Add PUT /api/v2/licenses/[licenseKey]/system-name (200/400 invalid_name/
404 unknown_license/409 name_taken, idempotent) and extend POST
/api/v2/license with an optional systemName and additive systemNameStatus
(ok/unchanged/conflict/invalid). Validation always succeeds; an omitted
field never changes or clears a name and empty/whitespace is invalid.
Adds server and helper tests covering trimming, bounds, case-insensitive
uniqueness, cross-account reuse, 404, and idempotency.
2026-09-29 09:51:54 -05:00
NeilClack 1d76e8dff9 Portal: add nullable System Name to LicenseKey with per-account unique index
Adds systemName (as typed, trimmed) and systemNameLower (case-folded) to
LicenseKey plus a unique index on (userId, systemNameLower) so names are
case-insensitively unique per account when non-null. Additive and
reversible; migration tested up/down. Regenerates the Prisma client.
2026-09-29 09:51:48 -05:00
NeilClack 179df1e252 Portal: 12-hour schedule times and dynamicAudio default on
Use TimeInput for Block start/end and show the day timeline axis with
AM/PM, so no 24-hour time is displayed in the schedule UI. New Blocks now
default dynamicAudio to true in the modal and server-side when the field
is absent; existing stored Blocks are untouched.
2026-09-29 08:53:22 -05:00
NeilClack 35558b0652 Portal: add reusable 12-hour TimeInput component
TimeInput displays and accepts AM/PM times, offers the 5-minute options as
suggestions, and allows typing any minute 00-59; its value model is a 24h
HH:MM string. Adds isomorphic parseTimeInput/hmToMinutes/fiveMinuteOptions
helpers and unit + component tests for them.
2026-09-29 08:53:17 -05:00
NeilClack 6c89a89050 docs: add Phase 0 schedule API/data contract and fixtures
Add docs/schedule-api-contract.md (current state, time semantics, schedule
data model, validation rules, endpoints, sync behavior, System Name rules,
dynamic audio, backward compatibility, per-phase touchpoints, open items,
changelog) and docs/fixtures/*.json (daily, weekly, none, midnight-crossing,
adjacent, dynamic-audio, and legacy v2 payloads). Kept byte-identical with
the radiantwave repo.
2026-09-29 08:36:31 -05:00
NeilClack dca9c54d83 Merge branch 'dev'
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 3m23s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 41s
2026-09-11 19:07:21 -05:00
NeilClack 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
69 changed files with 6474 additions and 784 deletions
+10 -4
View File
@@ -6,12 +6,15 @@ on:
jobs:
build-and-push:
runs-on: build-server
runs-on: orion-lxc
steps:
- name: Checkout
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
run: |
@@ -34,12 +37,15 @@ jobs:
migrate:
needs: build-and-push
runs-on: build-server
runs-on: orion-lxc
steps:
- name: Checkout
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
uses: https://github.com/actions/setup-node@v4
-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:.*$"
}
}
}
]
}
+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": []
}
+199 -9
View File
@@ -245,13 +245,16 @@ paths:
pairing.
Returns every Block with its start/end times and the ordered list of
selected Proclamation IDs. 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`.
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
@@ -309,6 +312,138 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/licenses/{licenseKey}/schedule:
get:
tags: [Schedule]
summary: Get a License Key's schedule (Kiosk-facing, conditional)
description: |
Kiosk-facing, License-Key-only schedule read. Returns the target payload
(`version`, `type`, `daily`, `weekly`, `blocks`). `version` is a
content-derived opaque string and is also the `ETag`; a matching
`If-None-Match` returns `304`. An unknown License Key is `404`; a key
with no Schedule returns `type: null` with empty lists. No
authentication (see the API description) beyond the 10/min/IP limit.
operationId: getLicenseSchedule
parameters:
- in: path
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
- in: header
name: If-None-Match
required: false
schema:
type: string
description: Prior ETag; when it matches, returns 304.
responses:
'200':
description: The target schedule payload.
headers:
ETag:
schema:
type: string
description: The quoted `version`.
content:
application/json:
schema:
$ref: '#/components/schemas/SchedulePayload'
'304':
description: Not modified; the caller keeps its cached payload.
headers:
ETag:
schema:
type: string
'404':
description: 'Unknown license (`unknown_license`).'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/licenses/{licenseKey}/system-name:
put:
tags: [License]
summary: Set a License Key's System Name (Kiosk-only)
description: |
Sets the human-readable System Name for a License Key. The Kiosk is the
only writer; the Portal displays the name read-only. The name is unique
per account, compared case-insensitively, when non-null. Idempotent:
re-sending the current name returns 200 without a write.
operationId: setSystemName
parameters:
- in: path
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [systemName]
properties:
systemName:
type: string
minLength: 1
maxLength: 40
responses:
'200':
description: The stored name.
content:
application/json:
schema:
type: object
required: [systemName, systemNameStatus]
properties:
systemName:
type: string
systemNameStatus:
type: string
enum: [ok]
'400':
description: 'Invalid name (`invalid_name`); empty/whitespace is invalid.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'404':
description: 'Unknown license (`unknown_license`).'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'409':
description: 'Name already used on the account (`name_taken`).'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/automations/create/account:
post:
tags: [Automations]
@@ -505,9 +640,16 @@ components:
type: string
format: email
maxLength: 254
systemName:
type: string
minLength: 1
maxLength: 40
description: |
Optional. The Kiosk's System Name (trimmed). Omitted means the stored
name is left untouched; empty/whitespace is invalid, never a clear.
LicenseResponse:
type: object
required: [key, email_address]
required: [key, email_address, systemNameStatus]
properties:
key:
type: string
@@ -515,6 +657,12 @@ components:
email_address:
type: string
format: email
systemNameStatus:
type: string
enum: [ok, unchanged, conflict, invalid]
description: |
Advisory outcome of the optional `systemName`. Validation itself
always succeeds. `unchanged` is returned when the field is omitted.
ScheduleBlock:
type: object
required:
@@ -576,9 +724,51 @@ components:
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
maxItems: 4
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:
+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,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 $$;
+31 -12
View File
@@ -28,7 +28,7 @@ model User {
profiles Profile[]
licenseKeys LicenseKey[]
userProclamations UserProclamations[]
schedule Schedule?
schedules Schedule[]
@@unique([email])
@@map("user")
@@ -205,10 +205,18 @@ model LicenseKey {
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
schedule Schedule?
// Human-readable label set only by the Kiosk (e.g. "Living Room"). Trimmed,
// 1-40 chars. Never a reference or foreign key; the License Key is the only
// identifier. `systemNameLower` is a case-folded copy used to enforce
// per-account, case-insensitive uniqueness when non-null.
systemName String?
systemNameLower String?
// Legacy subscription level. Retained in the database but unmanaged; hidden
// from the client and left NULL.
level String? @ignore
@@unique([userId, systemNameLower])
@@index([userId])
@@index([email])
}
@@ -256,31 +264,42 @@ model AuditLog {
@@map("AuditLog")
}
// A user's daily listening schedule. Exactly one per user (enforced by the
// unique userId); contains 0-4 time Blocks. Times are wall-clock minutes from
// midnight (0-1440), interpreted by the kiosk in the user's configured
// timezone. The portal never stores a timezone.
// 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())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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 non-overlapping time range within a Schedule, plus the ordered set
// of selected Proclamations to play during it. Overnight ranges are disallowed
// in v1, so startMinutes is always less than endMinutes.
// 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.
dynamicAudio Boolean @default(false)
// overriding any audio selections made on the device. Portal default: true.
dynamicAudio Boolean @default(true)
proclamations BlockProclamations[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+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);
});
});
+50 -118
View File
@@ -2,11 +2,13 @@
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import TimeInput from '$lib/components/TimeInput.svelte';
import {
MINUTE_STEP,
formatTime,
hmToMinutes,
isOvernight,
validateBlock,
toHm,
validateBlockAgainstSchedule,
type BlockSibling
} from '$lib/schedule-utils';
@@ -23,10 +25,13 @@
open = false,
mode = 'create',
blockId,
day = null,
licenseKeyId = '',
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = false,
initialDynamicAudio = true,
scheduleType = 'daily' as 'daily' | 'weekly' | null,
categories = [],
groups = [],
proclamations = [],
@@ -40,10 +45,13 @@
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[];
@@ -56,58 +64,38 @@
} = $props();
let step = $state<'details' | 'browse'>('details');
let startHour = $state('9');
let startMinute = $state('0');
let startPeriod = $state<'AM' | 'PM'>('AM');
let endHour = $state('10');
let endMinute = $state('0');
let endPeriod = $state<'AM' | 'PM'>('AM');
// 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(false);
let dynamicAudio = $state(true);
let error = $state('');
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM'];
function minuteOptions(current: string): string[] {
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
if (base.includes(current)) return base;
return [...base, current].sort((a, b) => Number(a) - Number(b));
function setStart(hm: string) {
const minutes = hmToMinutes(hm);
if (minutes !== null) startMinutes = minutes;
}
function to12Hour(totalMinutes: number): { hour: number; minute: number; period: 'AM' | 'PM' } {
const hours24 = Math.floor(totalMinutes / 60) % 24;
return {
hour: hours24 % 12 === 0 ? 12 : hours24 % 12,
minute: totalMinutes % 60,
period: hours24 < 12 ? 'AM' : 'PM'
};
}
function to24Hour(hour12: string, minute: string, period: 'AM' | 'PM'): number {
const hours = (Number(hour12) % 12) + (period === 'PM' ? 12 : 0);
return (hours * 60 + Number(minute)) | 0;
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;
const start = to12Hour(initialStartMinutes);
startHour = String(start.hour);
startMinute = String(start.minute);
startPeriod = start.period;
const end = to12Hour(initialEndMinutes);
endHour = String(end.hour);
endMinute = String(end.minute);
endPeriod = end.period;
startMinutes = initialStartMinutes;
endMinutes = initialEndMinutes;
startValid = true;
endValid = true;
selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = '';
step = 'details';
});
const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod));
const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod));
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
@@ -122,10 +110,12 @@
);
function validate(): string | null {
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
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;
}
@@ -210,6 +200,10 @@
{#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)} />
@@ -228,81 +222,19 @@
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Start time</span
>
<div class="flex items-center gap-2">
<select
aria-label="Start hour"
onchange={(e) => (startHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 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"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={startHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="Start minute"
onchange={(e) => (startMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 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"
>
{#each minuteOptions(startMinute) as minute (minute)}
<option value={minute} selected={startMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="Start period"
onchange={(e) => (startPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 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"
>
{#each periodOptions as period (period)}
<option value={period} selected={startPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<TimeInput
label="Start time"
value={toHm(startMinutes)}
onchange={setStart}
onvalidity={(valid) => (startValid = valid)}
/>
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>End time</span
>
<div class="flex items-center gap-2">
<select
aria-label="End hour"
onchange={(e) => (endHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 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"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={endHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="End minute"
onchange={(e) => (endMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 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"
>
{#each minuteOptions(endMinute) as minute (minute)}
<option value={minute} selected={endMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="End period"
onchange={(e) => (endPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 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"
>
{#each periodOptions as period (period)}
<option value={period} selected={endPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<TimeInput
label="End time"
value={toHm(endMinutes)}
onchange={setEnd}
onvalidity={(valid) => (endValid = valid)}
/>
<div>
<div class="flex items-center justify-between gap-2">
@@ -359,7 +291,7 @@
<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
? ' (next day)'
? ' (ends next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
@@ -46,53 +46,50 @@ function submit() {
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 separate hour and minute selects for start and end', async () => {
it('uses a 12-hour TimeInput for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Start minute$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'End time' })).toBeInTheDocument();
});
it('pre-fills the selects from the initial times', async () => {
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 hour$/ })).toHaveValue('7');
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('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('uses a 12-hour clock with an AM/PM select for each time', async () => {
it('shows a 12-hour clock with AM/PM', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('12');
await expect.element(page.getByRole('combobox', { name: /^End period$/ })).toHaveValue('AM');
// No 24-hour option exists any more.
await expect
.element(page.getByRole('combobox', { name: /^Start hour$/ }))
.not.toHaveValue('21');
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 when the AM/PM switch changes', async () => {
it('recomputes the time from typed input', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
const period = (await page
.getByRole('combobox', { name: /^End period$/ })
.element()) as HTMLSelectElement;
period.value = 'PM';
period.dispatchEvent(new Event('change', { bubbles: true }));
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();
@@ -103,7 +100,7 @@ describe('BlockModal', () => {
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 (next day)')).toBeInTheDocument();
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 () => {
@@ -113,7 +110,6 @@ describe('BlockModal', () => {
initialEndMinutes: 9 * 60
});
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect
@@ -124,7 +120,6 @@ describe('BlockModal', () => {
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
@@ -133,7 +128,6 @@ describe('BlockModal', () => {
it('does not show an error for a valid selection', async () => {
mount();
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
const result = submit();
expect(typeof result).toBe('function');
@@ -165,21 +159,32 @@ describe('BlockModal', () => {
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
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/ });
await expect.element(checkbox).toBeInTheDocument();
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();
@@ -188,6 +193,6 @@ describe('BlockModal', () => {
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('true');
await expect.poll(() => hidden.value).toBe('false');
});
});
@@ -1,173 +0,0 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(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 {
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
// Overnight blocks render as two segments: start→midnight and midnight→end.
const blockViews = $derived(
blocks.map((block) => ({
block,
overnight: isOvernight(block),
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
top: (segmentStart / MINUTES_PER_DAY) * 100,
height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100
}))
}))
);
</script>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{String(hour).padStart(2, '0')}:00
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (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}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blockViews as view (view.block.id)}
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.(view.block);
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${segment.top}%;height:${segment.height}%`}
>
{#if segmentIndex === 0}
<span class="block font-semibold">
{formatTime(view.block.startMinutes)} – {formatTime(
view.block.endMinutes
)}{view.overnight ? ' (next day)' : ''}
</span>
{:else}
<span class="block font-semibold">↳ continues</span>
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
{/each}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
@@ -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' });
});
});
File diff suppressed because one or more lines are too long
@@ -2020,7 +2020,9 @@ export const LicenseKeyScalarFieldEnum = {
createdAt: 'createdAt',
lastValidatedAt: 'lastValidatedAt',
userId: 'userId',
email: 'email'
email: 'email',
systemName: 'systemName',
systemNameLower: 'systemNameLower'
} as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
@@ -2067,7 +2069,9 @@ export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typ
export const ScheduleScalarFieldEnum = {
id: 'id',
licenseKeyId: 'licenseKeyId',
userId: 'userId',
type: 'type',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -2078,6 +2082,7 @@ export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typ
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
day: 'day',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
@@ -241,7 +241,9 @@ export const LicenseKeyScalarFieldEnum = {
createdAt: 'createdAt',
lastValidatedAt: 'lastValidatedAt',
userId: 'userId',
email: 'email'
email: 'email',
systemName: 'systemName',
systemNameLower: 'systemNameLower'
} as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
@@ -288,7 +290,9 @@ export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typ
export const ScheduleScalarFieldEnum = {
id: 'id',
licenseKeyId: 'licenseKeyId',
userId: 'userId',
type: 'type',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -299,6 +303,7 @@ export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typ
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
day: 'day',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
+40 -1
View File
@@ -39,6 +39,7 @@ export type BlockSumAggregateOutputType = {
export type BlockMinAggregateOutputType = {
id: string | null
scheduleId: string | null
day: string | null
startMinutes: number | null
endMinutes: number | null
dynamicAudio: boolean | null
@@ -49,6 +50,7 @@ export type BlockMinAggregateOutputType = {
export type BlockMaxAggregateOutputType = {
id: string | null
scheduleId: string | null
day: string | null
startMinutes: number | null
endMinutes: number | null
dynamicAudio: boolean | null
@@ -59,6 +61,7 @@ export type BlockMaxAggregateOutputType = {
export type BlockCountAggregateOutputType = {
id: number
scheduleId: number
day: number
startMinutes: number
endMinutes: number
dynamicAudio: number
@@ -81,6 +84,7 @@ export type BlockSumAggregateInputType = {
export type BlockMinAggregateInputType = {
id?: true
scheduleId?: true
day?: true
startMinutes?: true
endMinutes?: true
dynamicAudio?: true
@@ -91,6 +95,7 @@ export type BlockMinAggregateInputType = {
export type BlockMaxAggregateInputType = {
id?: true
scheduleId?: true
day?: true
startMinutes?: true
endMinutes?: true
dynamicAudio?: true
@@ -101,6 +106,7 @@ export type BlockMaxAggregateInputType = {
export type BlockCountAggregateInputType = {
id?: true
scheduleId?: true
day?: true
startMinutes?: true
endMinutes?: true
dynamicAudio?: true
@@ -198,6 +204,7 @@ export type BlockGroupByArgs<ExtArgs extends runtime.Types.Extensions.InternalAr
export type BlockGroupByOutputType = {
id: string
scheduleId: string
day: string | null
startMinutes: number
endMinutes: number
dynamicAudio: boolean
@@ -231,6 +238,7 @@ export type BlockWhereInput = {
NOT?: Prisma.BlockWhereInput | Prisma.BlockWhereInput[]
id?: Prisma.StringFilter<"Block"> | string
scheduleId?: Prisma.StringFilter<"Block"> | string
day?: Prisma.StringNullableFilter<"Block"> | string | null
startMinutes?: Prisma.IntFilter<"Block"> | number
endMinutes?: Prisma.IntFilter<"Block"> | number
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
@@ -243,6 +251,7 @@ export type BlockWhereInput = {
export type BlockOrderByWithRelationInput = {
id?: Prisma.SortOrder
scheduleId?: Prisma.SortOrder
day?: Prisma.SortOrderInput | Prisma.SortOrder
startMinutes?: Prisma.SortOrder
endMinutes?: Prisma.SortOrder
dynamicAudio?: Prisma.SortOrder
@@ -258,6 +267,7 @@ export type BlockWhereUniqueInput = Prisma.AtLeast<{
OR?: Prisma.BlockWhereInput[]
NOT?: Prisma.BlockWhereInput | Prisma.BlockWhereInput[]
scheduleId?: Prisma.StringFilter<"Block"> | string
day?: Prisma.StringNullableFilter<"Block"> | string | null
startMinutes?: Prisma.IntFilter<"Block"> | number
endMinutes?: Prisma.IntFilter<"Block"> | number
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
@@ -270,6 +280,7 @@ export type BlockWhereUniqueInput = Prisma.AtLeast<{
export type BlockOrderByWithAggregationInput = {
id?: Prisma.SortOrder
scheduleId?: Prisma.SortOrder
day?: Prisma.SortOrderInput | Prisma.SortOrder
startMinutes?: Prisma.SortOrder
endMinutes?: Prisma.SortOrder
dynamicAudio?: Prisma.SortOrder
@@ -288,6 +299,7 @@ export type BlockScalarWhereWithAggregatesInput = {
NOT?: Prisma.BlockScalarWhereWithAggregatesInput | Prisma.BlockScalarWhereWithAggregatesInput[]
id?: Prisma.StringWithAggregatesFilter<"Block"> | string
scheduleId?: Prisma.StringWithAggregatesFilter<"Block"> | string
day?: Prisma.StringNullableWithAggregatesFilter<"Block"> | string | null
startMinutes?: Prisma.IntWithAggregatesFilter<"Block"> | number
endMinutes?: Prisma.IntWithAggregatesFilter<"Block"> | number
dynamicAudio?: Prisma.BoolWithAggregatesFilter<"Block"> | boolean
@@ -297,6 +309,7 @@ export type BlockScalarWhereWithAggregatesInput = {
export type BlockCreateInput = {
id?: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -309,6 +322,7 @@ export type BlockCreateInput = {
export type BlockUncheckedCreateInput = {
id?: string
scheduleId: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -319,6 +333,7 @@ export type BlockUncheckedCreateInput = {
export type BlockUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -331,6 +346,7 @@ export type BlockUpdateInput = {
export type BlockUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -342,6 +358,7 @@ export type BlockUncheckedUpdateInput = {
export type BlockCreateManyInput = {
id?: string
scheduleId: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -351,6 +368,7 @@ export type BlockCreateManyInput = {
export type BlockUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -361,6 +379,7 @@ export type BlockUpdateManyMutationInput = {
export type BlockUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -381,6 +400,7 @@ export type BlockOrderByRelationAggregateInput = {
export type BlockCountOrderByAggregateInput = {
id?: Prisma.SortOrder
scheduleId?: Prisma.SortOrder
day?: Prisma.SortOrder
startMinutes?: Prisma.SortOrder
endMinutes?: Prisma.SortOrder
dynamicAudio?: Prisma.SortOrder
@@ -396,6 +416,7 @@ export type BlockAvgOrderByAggregateInput = {
export type BlockMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
scheduleId?: Prisma.SortOrder
day?: Prisma.SortOrder
startMinutes?: Prisma.SortOrder
endMinutes?: Prisma.SortOrder
dynamicAudio?: Prisma.SortOrder
@@ -406,6 +427,7 @@ export type BlockMaxOrderByAggregateInput = {
export type BlockMinOrderByAggregateInput = {
id?: Prisma.SortOrder
scheduleId?: Prisma.SortOrder
day?: Prisma.SortOrder
startMinutes?: Prisma.SortOrder
endMinutes?: Prisma.SortOrder
dynamicAudio?: Prisma.SortOrder
@@ -481,6 +503,7 @@ export type BlockUpdateOneRequiredWithoutProclamationsNestedInput = {
export type BlockCreateWithoutScheduleInput = {
id?: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -491,6 +514,7 @@ export type BlockCreateWithoutScheduleInput = {
export type BlockUncheckedCreateWithoutScheduleInput = {
id?: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -531,6 +555,7 @@ export type BlockScalarWhereInput = {
NOT?: Prisma.BlockScalarWhereInput | Prisma.BlockScalarWhereInput[]
id?: Prisma.StringFilter<"Block"> | string
scheduleId?: Prisma.StringFilter<"Block"> | string
day?: Prisma.StringNullableFilter<"Block"> | string | null
startMinutes?: Prisma.IntFilter<"Block"> | number
endMinutes?: Prisma.IntFilter<"Block"> | number
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
@@ -540,6 +565,7 @@ export type BlockScalarWhereInput = {
export type BlockCreateWithoutProclamationsInput = {
id?: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -551,6 +577,7 @@ export type BlockCreateWithoutProclamationsInput = {
export type BlockUncheckedCreateWithoutProclamationsInput = {
id?: string
scheduleId: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -576,6 +603,7 @@ export type BlockUpdateToOneWithWhereWithoutProclamationsInput = {
export type BlockUpdateWithoutProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -587,6 +615,7 @@ export type BlockUpdateWithoutProclamationsInput = {
export type BlockUncheckedUpdateWithoutProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -596,6 +625,7 @@ export type BlockUncheckedUpdateWithoutProclamationsInput = {
export type BlockCreateManyScheduleInput = {
id?: string
day?: string | null
startMinutes: number
endMinutes: number
dynamicAudio?: boolean
@@ -605,6 +635,7 @@ export type BlockCreateManyScheduleInput = {
export type BlockUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -615,6 +646,7 @@ export type BlockUpdateWithoutScheduleInput = {
export type BlockUncheckedUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -625,6 +657,7 @@ export type BlockUncheckedUpdateWithoutScheduleInput = {
export type BlockUncheckedUpdateManyWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
@@ -666,6 +699,7 @@ export type BlockCountOutputTypeCountProclamationsArgs<ExtArgs extends runtime.T
export type BlockSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
scheduleId?: boolean
day?: boolean
startMinutes?: boolean
endMinutes?: boolean
dynamicAudio?: boolean
@@ -679,6 +713,7 @@ export type BlockSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
export type BlockSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
scheduleId?: boolean
day?: boolean
startMinutes?: boolean
endMinutes?: boolean
dynamicAudio?: boolean
@@ -690,6 +725,7 @@ export type BlockSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensi
export type BlockSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
scheduleId?: boolean
day?: boolean
startMinutes?: boolean
endMinutes?: boolean
dynamicAudio?: boolean
@@ -701,6 +737,7 @@ export type BlockSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensi
export type BlockSelectScalar = {
id?: boolean
scheduleId?: boolean
day?: boolean
startMinutes?: boolean
endMinutes?: boolean
dynamicAudio?: boolean
@@ -708,7 +745,7 @@ export type BlockSelectScalar = {
updatedAt?: boolean
}
export type BlockOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "scheduleId" | "startMinutes" | "endMinutes" | "dynamicAudio" | "createdAt" | "updatedAt", ExtArgs["result"]["block"]>
export type BlockOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "scheduleId" | "day" | "startMinutes" | "endMinutes" | "dynamicAudio" | "createdAt" | "updatedAt", ExtArgs["result"]["block"]>
export type BlockInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
schedule?: boolean | Prisma.ScheduleDefaultArgs<ExtArgs>
proclamations?: boolean | Prisma.Block$proclamationsArgs<ExtArgs>
@@ -730,6 +767,7 @@ export type $BlockPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
scheduleId: string
day: string | null
startMinutes: number
endMinutes: number
dynamicAudio: boolean
@@ -1162,6 +1200,7 @@ export interface Prisma__BlockClient<T, Null = never, ExtArgs extends runtime.Ty
export interface BlockFieldRefs {
readonly id: Prisma.FieldRef<"Block", 'String'>
readonly scheduleId: Prisma.FieldRef<"Block", 'String'>
readonly day: Prisma.FieldRef<"Block", 'String'>
readonly startMinutes: Prisma.FieldRef<"Block", 'Int'>
readonly endMinutes: Prisma.FieldRef<"Block", 'Int'>
readonly dynamicAudio: Prisma.FieldRef<"Block", 'Boolean'>
+197 -2
View File
@@ -32,6 +32,8 @@ export type LicenseKeyMinAggregateOutputType = {
lastValidatedAt: Date | null
userId: string | null
email: string | null
systemName: string | null
systemNameLower: string | null
}
export type LicenseKeyMaxAggregateOutputType = {
@@ -42,6 +44,8 @@ export type LicenseKeyMaxAggregateOutputType = {
lastValidatedAt: Date | null
userId: string | null
email: string | null
systemName: string | null
systemNameLower: string | null
}
export type LicenseKeyCountAggregateOutputType = {
@@ -52,6 +56,8 @@ export type LicenseKeyCountAggregateOutputType = {
lastValidatedAt: number
userId: number
email: number
systemName: number
systemNameLower: number
_all: number
}
@@ -64,6 +70,8 @@ export type LicenseKeyMinAggregateInputType = {
lastValidatedAt?: true
userId?: true
email?: true
systemName?: true
systemNameLower?: true
}
export type LicenseKeyMaxAggregateInputType = {
@@ -74,6 +82,8 @@ export type LicenseKeyMaxAggregateInputType = {
lastValidatedAt?: true
userId?: true
email?: true
systemName?: true
systemNameLower?: true
}
export type LicenseKeyCountAggregateInputType = {
@@ -84,6 +94,8 @@ export type LicenseKeyCountAggregateInputType = {
lastValidatedAt?: true
userId?: true
email?: true
systemName?: true
systemNameLower?: true
_all?: true
}
@@ -167,6 +179,8 @@ export type LicenseKeyGroupByOutputType = {
lastValidatedAt: Date | null
userId: string | null
email: string | null
systemName: string | null
systemNameLower: string | null
_count: LicenseKeyCountAggregateOutputType | null
_min: LicenseKeyMinAggregateOutputType | null
_max: LicenseKeyMaxAggregateOutputType | null
@@ -198,7 +212,10 @@ export type LicenseKeyWhereInput = {
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: 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
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}
export type LicenseKeyOrderByWithRelationInput = {
@@ -209,12 +226,16 @@ export type LicenseKeyOrderByWithRelationInput = {
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
systemName?: Prisma.SortOrderInput | Prisma.SortOrder
systemNameLower?: Prisma.SortOrderInput | Prisma.SortOrder
user?: Prisma.UserOrderByWithRelationInput
schedule?: Prisma.ScheduleOrderByWithRelationInput
}
export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
id?: string
key?: string
userId_systemNameLower?: Prisma.LicenseKeyUserIdSystemNameLowerCompoundUniqueInput
AND?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
OR?: Prisma.LicenseKeyWhereInput[]
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
@@ -223,8 +244,11 @@ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: 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
}, "id" | "key">
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}, "id" | "key" | "userId_systemNameLower">
export type LicenseKeyOrderByWithAggregationInput = {
id?: Prisma.SortOrder
@@ -234,6 +258,8 @@ export type LicenseKeyOrderByWithAggregationInput = {
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
systemName?: Prisma.SortOrderInput | Prisma.SortOrder
systemNameLower?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
_max?: Prisma.LicenseKeyMaxOrderByAggregateInput
_min?: Prisma.LicenseKeyMinOrderByAggregateInput
@@ -250,6 +276,8 @@ export type LicenseKeyScalarWhereWithAggregatesInput = {
lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null
userId?: 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 = {
@@ -259,7 +287,10 @@ export type LicenseKeyCreateInput = {
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutLicenseKeyInput
}
export type LicenseKeyUncheckedCreateInput = {
@@ -270,6 +301,9 @@ export type LicenseKeyUncheckedCreateInput = {
lastValidatedAt?: Date | string | null
userId?: string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput
}
export type LicenseKeyUpdateInput = {
@@ -279,7 +313,10 @@ export type LicenseKeyUpdateInput = {
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
schedule?: Prisma.ScheduleUpdateOneWithoutLicenseKeyNestedInput
}
export type LicenseKeyUncheckedUpdateInput = {
@@ -290,6 +327,9 @@ export type LicenseKeyUncheckedUpdateInput = {
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
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput
}
export type LicenseKeyCreateManyInput = {
@@ -300,6 +340,8 @@ export type LicenseKeyCreateManyInput = {
lastValidatedAt?: Date | string | null
userId?: string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
}
export type LicenseKeyUpdateManyMutationInput = {
@@ -309,6 +351,8 @@ export type LicenseKeyUpdateManyMutationInput = {
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
}
export type LicenseKeyUncheckedUpdateManyInput = {
@@ -319,6 +363,8 @@ export type LicenseKeyUncheckedUpdateManyInput = {
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 LicenseKeyListRelationFilter = {
@@ -331,6 +377,11 @@ export type LicenseKeyOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type LicenseKeyUserIdSystemNameLowerCompoundUniqueInput = {
userId: string
systemNameLower: string
}
export type LicenseKeyCountOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
@@ -339,6 +390,8 @@ export type LicenseKeyCountOrderByAggregateInput = {
lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
systemName?: Prisma.SortOrder
systemNameLower?: Prisma.SortOrder
}
export type LicenseKeyMaxOrderByAggregateInput = {
@@ -349,6 +402,8 @@ export type LicenseKeyMaxOrderByAggregateInput = {
lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
systemName?: Prisma.SortOrder
systemNameLower?: Prisma.SortOrder
}
export type LicenseKeyMinOrderByAggregateInput = {
@@ -359,6 +414,13 @@ export type LicenseKeyMinOrderByAggregateInput = {
lastValidatedAt?: Prisma.SortOrder
userId?: 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 = {
@@ -403,6 +465,22 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserNestedInput = {
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 = {
id?: string
key: string
@@ -410,6 +488,9 @@ export type LicenseKeyCreateWithoutUserInput = {
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
schedule?: Prisma.ScheduleCreateNestedOneWithoutLicenseKeyInput
}
export type LicenseKeyUncheckedCreateWithoutUserInput = {
@@ -419,6 +500,9 @@ export type LicenseKeyUncheckedCreateWithoutUserInput = {
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput
}
export type LicenseKeyCreateOrConnectWithoutUserInput = {
@@ -458,6 +542,72 @@ export type LicenseKeyScalarWhereInput = {
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: 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 = {
@@ -467,6 +617,8 @@ export type LicenseKeyCreateManyUserInput = {
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
systemName?: string | null
systemNameLower?: string | null
}
export type LicenseKeyUpdateWithoutUserInput = {
@@ -476,6 +628,9 @@ export type LicenseKeyUpdateWithoutUserInput = {
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
schedule?: Prisma.ScheduleUpdateOneWithoutLicenseKeyNestedInput
}
export type LicenseKeyUncheckedUpdateWithoutUserInput = {
@@ -485,6 +640,9 @@ export type LicenseKeyUncheckedUpdateWithoutUserInput = {
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
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput
}
export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
@@ -494,6 +652,8 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
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
}
@@ -506,7 +666,10 @@ export type LicenseKeySelect<ExtArgs extends runtime.Types.Extensions.InternalAr
lastValidatedAt?: boolean
userId?: boolean
email?: boolean
systemName?: boolean
systemNameLower?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
schedule?: boolean | Prisma.LicenseKey$scheduleArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]>
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
userId?: boolean
email?: boolean
systemName?: boolean
systemNameLower?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]>
@@ -528,6 +693,8 @@ export type LicenseKeySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Ex
lastValidatedAt?: boolean
userId?: boolean
email?: boolean
systemName?: boolean
systemNameLower?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]>
@@ -539,11 +706,14 @@ export type LicenseKeySelectScalar = {
lastValidatedAt?: boolean
userId?: 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> = {
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> = {
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
@@ -556,6 +726,7 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
name: "LicenseKey"
objects: {
user: Prisma.$UserPayload<ExtArgs> | null
schedule: Prisma.$SchedulePayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -565,6 +736,8 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
lastValidatedAt: Date | null
userId: string | null
email: string | null
systemName: string | null
systemNameLower: string | null
}, ExtArgs["result"]["licenseKey"]>
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> {
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>
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.
* @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 userId: 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
}
/**
* 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
*/
+315 -53
View File
@@ -26,21 +26,27 @@ export type AggregateSchedule = {
export type ScheduleMinAggregateOutputType = {
id: string | null
licenseKeyId: string | null
userId: string | null
type: string | null
createdAt: Date | null
updatedAt: Date | null
}
export type ScheduleMaxAggregateOutputType = {
id: string | null
licenseKeyId: string | null
userId: string | null
type: string | null
createdAt: Date | null
updatedAt: Date | null
}
export type ScheduleCountAggregateOutputType = {
id: number
licenseKeyId: number
userId: number
type: number
createdAt: number
updatedAt: number
_all: number
@@ -49,21 +55,27 @@ export type ScheduleCountAggregateOutputType = {
export type ScheduleMinAggregateInputType = {
id?: true
licenseKeyId?: true
userId?: true
type?: true
createdAt?: true
updatedAt?: true
}
export type ScheduleMaxAggregateInputType = {
id?: true
licenseKeyId?: true
userId?: true
type?: true
createdAt?: true
updatedAt?: true
}
export type ScheduleCountAggregateInputType = {
id?: true
licenseKeyId?: true
userId?: true
type?: true
createdAt?: true
updatedAt?: true
_all?: true
@@ -143,7 +155,9 @@ export type ScheduleGroupByArgs<ExtArgs extends runtime.Types.Extensions.Interna
export type ScheduleGroupByOutputType = {
id: string
userId: string
licenseKeyId: string | null
userId: string | null
type: string | null
createdAt: Date
updatedAt: Date
_count: ScheduleCountAggregateOutputType | null
@@ -171,37 +185,48 @@ export type ScheduleWhereInput = {
OR?: Prisma.ScheduleWhereInput[]
NOT?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
id?: Prisma.StringFilter<"Schedule"> | string
userId?: Prisma.StringFilter<"Schedule"> | string
licenseKeyId?: Prisma.StringNullableFilter<"Schedule"> | string | null
userId?: Prisma.StringNullableFilter<"Schedule"> | string | null
type?: Prisma.StringNullableFilter<"Schedule"> | string | null
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
licenseKey?: Prisma.XOR<Prisma.LicenseKeyNullableScalarRelationFilter, Prisma.LicenseKeyWhereInput> | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
blocks?: Prisma.BlockListRelationFilter
}
export type ScheduleOrderByWithRelationInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
licenseKeyId?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
type?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
licenseKey?: Prisma.LicenseKeyOrderByWithRelationInput
user?: Prisma.UserOrderByWithRelationInput
blocks?: Prisma.BlockOrderByRelationAggregateInput
}
export type ScheduleWhereUniqueInput = Prisma.AtLeast<{
id?: string
userId?: string
licenseKeyId?: string
AND?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
OR?: Prisma.ScheduleWhereInput[]
NOT?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
userId?: Prisma.StringNullableFilter<"Schedule"> | string | null
type?: Prisma.StringNullableFilter<"Schedule"> | string | null
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
licenseKey?: Prisma.XOR<Prisma.LicenseKeyNullableScalarRelationFilter, Prisma.LicenseKeyWhereInput> | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
blocks?: Prisma.BlockListRelationFilter
}, "id" | "userId">
}, "id" | "licenseKeyId">
export type ScheduleOrderByWithAggregationInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
licenseKeyId?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
type?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.ScheduleCountOrderByAggregateInput
@@ -214,22 +239,28 @@ export type ScheduleScalarWhereWithAggregatesInput = {
OR?: Prisma.ScheduleScalarWhereWithAggregatesInput[]
NOT?: Prisma.ScheduleScalarWhereWithAggregatesInput | Prisma.ScheduleScalarWhereWithAggregatesInput[]
id?: Prisma.StringWithAggregatesFilter<"Schedule"> | string
userId?: Prisma.StringWithAggregatesFilter<"Schedule"> | string
licenseKeyId?: Prisma.StringNullableWithAggregatesFilter<"Schedule"> | string | null
userId?: Prisma.StringNullableWithAggregatesFilter<"Schedule"> | string | null
type?: Prisma.StringNullableWithAggregatesFilter<"Schedule"> | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Schedule"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Schedule"> | Date | string
}
export type ScheduleCreateInput = {
id?: string
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
user: Prisma.UserCreateNestedOneWithoutScheduleInput
licenseKey?: Prisma.LicenseKeyCreateNestedOneWithoutScheduleInput
user?: Prisma.UserCreateNestedOneWithoutSchedulesInput
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
}
export type ScheduleUncheckedCreateInput = {
id?: string
userId: string
licenseKeyId?: string | null
userId?: string | null
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
@@ -237,15 +268,19 @@ export type ScheduleUncheckedCreateInput = {
export type ScheduleUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
user?: Prisma.UserUpdateOneRequiredWithoutScheduleNestedInput
licenseKey?: Prisma.LicenseKeyUpdateOneWithoutScheduleNestedInput
user?: Prisma.UserUpdateOneWithoutSchedulesNestedInput
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
}
export type ScheduleUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
@@ -253,24 +288,39 @@ export type ScheduleUncheckedUpdateInput = {
export type ScheduleCreateManyInput = {
id?: string
userId: string
licenseKeyId?: string | null
userId?: string | null
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type ScheduleUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type ScheduleUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type ScheduleListRelationFilter = {
every?: Prisma.ScheduleWhereInput
some?: Prisma.ScheduleWhereInput
none?: Prisma.ScheduleWhereInput
}
export type ScheduleOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type ScheduleNullableScalarRelationFilter = {
is?: Prisma.ScheduleWhereInput | null
isNot?: Prisma.ScheduleWhereInput | null
@@ -278,21 +328,27 @@ export type ScheduleNullableScalarRelationFilter = {
export type ScheduleCountOrderByAggregateInput = {
id?: Prisma.SortOrder
licenseKeyId?: Prisma.SortOrder
userId?: Prisma.SortOrder
type?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
export type ScheduleMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
licenseKeyId?: Prisma.SortOrder
userId?: Prisma.SortOrder
type?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
export type ScheduleMinOrderByAggregateInput = {
id?: Prisma.SortOrder
licenseKeyId?: Prisma.SortOrder
userId?: Prisma.SortOrder
type?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -302,36 +358,78 @@ export type ScheduleScalarRelationFilter = {
isNot?: Prisma.ScheduleWhereInput
}
export type ScheduleCreateNestedOneWithoutUserInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
export type ScheduleCreateNestedManyWithoutUserInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
}
export type ScheduleUncheckedCreateNestedManyWithoutUserInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
}
export type ScheduleUpdateManyWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
upsert?: Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput[]
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
set?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
disconnect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
delete?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
update?: Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput[]
updateMany?: Prisma.ScheduleUpdateManyWithWhereWithoutUserInput | Prisma.ScheduleUpdateManyWithWhereWithoutUserInput[]
deleteMany?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
}
export type ScheduleUncheckedUpdateManyWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
upsert?: Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput[]
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
set?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
disconnect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
delete?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
update?: Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput[]
updateMany?: Prisma.ScheduleUpdateManyWithWhereWithoutUserInput | Prisma.ScheduleUpdateManyWithWhereWithoutUserInput[]
deleteMany?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
}
export type ScheduleCreateNestedOneWithoutLicenseKeyInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
connect?: Prisma.ScheduleWhereUniqueInput
}
export type ScheduleUncheckedCreateNestedOneWithoutUserInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
export type ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
connect?: Prisma.ScheduleWhereUniqueInput
}
export type ScheduleUpdateOneWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
upsert?: Prisma.ScheduleUpsertWithoutUserInput
export type ScheduleUpdateOneWithoutLicenseKeyNestedInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
upsert?: Prisma.ScheduleUpsertWithoutLicenseKeyInput
disconnect?: Prisma.ScheduleWhereInput | boolean
delete?: Prisma.ScheduleWhereInput | boolean
connect?: Prisma.ScheduleWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutUserInput, Prisma.ScheduleUpdateWithoutUserInput>, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput, Prisma.ScheduleUpdateWithoutLicenseKeyInput>, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
}
export type ScheduleUncheckedUpdateOneWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
upsert?: Prisma.ScheduleUpsertWithoutUserInput
export type ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput = {
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
upsert?: Prisma.ScheduleUpsertWithoutLicenseKeyInput
disconnect?: Prisma.ScheduleWhereInput | boolean
delete?: Prisma.ScheduleWhereInput | boolean
connect?: Prisma.ScheduleWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutUserInput, Prisma.ScheduleUpdateWithoutUserInput>, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput, Prisma.ScheduleUpdateWithoutLicenseKeyInput>, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
}
export type ScheduleCreateNestedOneWithoutBlocksInput = {
@@ -350,13 +448,17 @@ export type ScheduleUpdateOneRequiredWithoutBlocksNestedInput = {
export type ScheduleCreateWithoutUserInput = {
id?: string
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
licenseKey?: Prisma.LicenseKeyCreateNestedOneWithoutScheduleInput
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
}
export type ScheduleUncheckedCreateWithoutUserInput = {
id?: string
licenseKeyId?: string | null
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
@@ -367,26 +469,86 @@ export type ScheduleCreateOrConnectWithoutUserInput = {
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
}
export type ScheduleUpsertWithoutUserInput = {
update: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
where?: Prisma.ScheduleWhereInput
export type ScheduleCreateManyUserInputEnvelope = {
data: Prisma.ScheduleCreateManyUserInput | Prisma.ScheduleCreateManyUserInput[]
skipDuplicates?: boolean
}
export type ScheduleUpdateToOneWithWhereWithoutUserInput = {
where?: Prisma.ScheduleWhereInput
export type ScheduleUpsertWithWhereUniqueWithoutUserInput = {
where: Prisma.ScheduleWhereUniqueInput
update: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
}
export type ScheduleUpdateWithWhereUniqueWithoutUserInput = {
where: Prisma.ScheduleWhereUniqueInput
data: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
}
export type ScheduleUpdateWithoutUserInput = {
export type ScheduleUpdateManyWithWhereWithoutUserInput = {
where: Prisma.ScheduleScalarWhereInput
data: Prisma.XOR<Prisma.ScheduleUpdateManyMutationInput, Prisma.ScheduleUncheckedUpdateManyWithoutUserInput>
}
export type ScheduleScalarWhereInput = {
AND?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
OR?: Prisma.ScheduleScalarWhereInput[]
NOT?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
id?: Prisma.StringFilter<"Schedule"> | string
licenseKeyId?: Prisma.StringNullableFilter<"Schedule"> | string | null
userId?: Prisma.StringNullableFilter<"Schedule"> | string | null
type?: Prisma.StringNullableFilter<"Schedule"> | string | null
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
}
export type ScheduleCreateWithoutLicenseKeyInput = {
id?: string
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
user?: Prisma.UserCreateNestedOneWithoutSchedulesInput
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
}
export type ScheduleUncheckedCreateWithoutLicenseKeyInput = {
id?: string
userId?: string | null
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
}
export type ScheduleCreateOrConnectWithoutLicenseKeyInput = {
where: Prisma.ScheduleWhereUniqueInput
create: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
}
export type ScheduleUpsertWithoutLicenseKeyInput = {
update: Prisma.XOR<Prisma.ScheduleUpdateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
create: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
where?: Prisma.ScheduleWhereInput
}
export type ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput = {
where?: Prisma.ScheduleWhereInput
data: Prisma.XOR<Prisma.ScheduleUpdateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
}
export type ScheduleUpdateWithoutLicenseKeyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
user?: Prisma.UserUpdateOneWithoutSchedulesNestedInput
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
}
export type ScheduleUncheckedUpdateWithoutUserInput = {
export type ScheduleUncheckedUpdateWithoutLicenseKeyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
@@ -394,14 +556,18 @@ export type ScheduleUncheckedUpdateWithoutUserInput = {
export type ScheduleCreateWithoutBlocksInput = {
id?: string
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
user: Prisma.UserCreateNestedOneWithoutScheduleInput
licenseKey?: Prisma.LicenseKeyCreateNestedOneWithoutScheduleInput
user?: Prisma.UserCreateNestedOneWithoutSchedulesInput
}
export type ScheduleUncheckedCreateWithoutBlocksInput = {
id?: string
userId: string
licenseKeyId?: string | null
userId?: string | null
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -424,14 +590,52 @@ export type ScheduleUpdateToOneWithWhereWithoutBlocksInput = {
export type ScheduleUpdateWithoutBlocksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
user?: Prisma.UserUpdateOneRequiredWithoutScheduleNestedInput
licenseKey?: Prisma.LicenseKeyUpdateOneWithoutScheduleNestedInput
user?: Prisma.UserUpdateOneWithoutSchedulesNestedInput
}
export type ScheduleUncheckedUpdateWithoutBlocksInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type ScheduleCreateManyUserInput = {
id?: string
licenseKeyId?: string | null
type?: string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type ScheduleUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
licenseKey?: Prisma.LicenseKeyUpdateOneWithoutScheduleNestedInput
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
}
export type ScheduleUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
}
export type ScheduleUncheckedUpdateManyWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -469,59 +673,76 @@ export type ScheduleCountOutputTypeCountBlocksArgs<ExtArgs extends runtime.Types
export type ScheduleSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
licenseKeyId?: boolean
userId?: boolean
type?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
blocks?: boolean | Prisma.Schedule$blocksArgs<ExtArgs>
_count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["schedule"]>
export type ScheduleSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
licenseKeyId?: boolean
userId?: boolean
type?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
}, ExtArgs["result"]["schedule"]>
export type ScheduleSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
licenseKeyId?: boolean
userId?: boolean
type?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
}, ExtArgs["result"]["schedule"]>
export type ScheduleSelectScalar = {
id?: boolean
licenseKeyId?: boolean
userId?: boolean
type?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type ScheduleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "userId" | "createdAt" | "updatedAt", ExtArgs["result"]["schedule"]>
export type ScheduleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "licenseKeyId" | "userId" | "type" | "createdAt" | "updatedAt", ExtArgs["result"]["schedule"]>
export type ScheduleInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
blocks?: boolean | Prisma.Schedule$blocksArgs<ExtArgs>
_count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs<ExtArgs>
}
export type ScheduleIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
}
export type ScheduleIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
}
export type $SchedulePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Schedule"
objects: {
user: Prisma.$UserPayload<ExtArgs>
licenseKey: Prisma.$LicenseKeyPayload<ExtArgs> | null
user: Prisma.$UserPayload<ExtArgs> | null
blocks: Prisma.$BlockPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
userId: string
licenseKeyId: string | null
userId: string | null
type: string | null
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["schedule"]>
@@ -918,7 +1139,8 @@ readonly fields: ScheduleFieldRefs;
*/
export interface Prisma__ScheduleClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
user<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
licenseKey<T extends Prisma.Schedule$licenseKeyArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$licenseKeyArgs<ExtArgs>>): Prisma.Prisma__LicenseKeyClient<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
user<T extends Prisma.Schedule$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
blocks<T extends Prisma.Schedule$blocksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$blocksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -950,7 +1172,9 @@ export interface Prisma__ScheduleClient<T, Null = never, ExtArgs extends runtime
*/
export interface ScheduleFieldRefs {
readonly id: Prisma.FieldRef<"Schedule", 'String'>
readonly licenseKeyId: Prisma.FieldRef<"Schedule", 'String'>
readonly userId: Prisma.FieldRef<"Schedule", 'String'>
readonly type: Prisma.FieldRef<"Schedule", 'String'>
readonly createdAt: Prisma.FieldRef<"Schedule", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Schedule", 'DateTime'>
}
@@ -1353,6 +1577,44 @@ export type ScheduleDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Inte
limit?: number
}
/**
* Schedule.licenseKey
*/
export type Schedule$licenseKeyArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the LicenseKey
*/
select?: Prisma.LicenseKeySelect<ExtArgs> | null
/**
* Omit specific fields from the LicenseKey
*/
omit?: Prisma.LicenseKeyOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.LicenseKeyInclude<ExtArgs> | null
where?: Prisma.LicenseKeyWhereInput
}
/**
* Schedule.user
*/
export type Schedule$userArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the User
*/
select?: Prisma.UserSelect<ExtArgs> | null
/**
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
where?: Prisma.UserWhereInput
}
/**
* Schedule.blocks
*/
+72 -56
View File
@@ -246,7 +246,7 @@ export type UserWhereInput = {
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
schedules?: Prisma.ScheduleListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -264,7 +264,7 @@ export type UserOrderByWithRelationInput = {
profiles?: Prisma.ProfileOrderByRelationAggregateInput
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
schedule?: Prisma.ScheduleOrderByWithRelationInput
schedules?: Prisma.ScheduleOrderByRelationAggregateInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -285,7 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
schedules?: Prisma.ScheduleListRelationFilter
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -332,7 +332,7 @@ export type UserCreateInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -349,7 +349,7 @@ export type UserUncheckedCreateInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserUpdateInput = {
@@ -366,7 +366,7 @@ export type UserUpdateInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -383,7 +383,7 @@ export type UserUncheckedUpdateInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -619,18 +619,20 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
}
export type UserCreateNestedOneWithoutScheduleInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
export type UserCreateNestedOneWithoutSchedulesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutScheduleNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
upsert?: Prisma.UserUpsertWithoutScheduleInput
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.UserUpdateToOneWithWhereWithoutScheduleInput, Prisma.UserUpdateWithoutScheduleInput>, Prisma.UserUncheckedUpdateWithoutScheduleInput>
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutSchedulesInput, Prisma.UserUpdateWithoutSchedulesInput>, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
}
export type UserCreateWithoutProfilesInput = {
@@ -646,7 +648,7 @@ export type UserCreateWithoutProfilesInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutProfilesInput = {
@@ -662,7 +664,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutProfilesInput = {
@@ -694,7 +696,7 @@ export type UserUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutProfilesInput = {
@@ -710,7 +712,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutSessionsInput = {
@@ -726,7 +728,7 @@ export type UserCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutSessionsInput = {
@@ -742,7 +744,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutSessionsInput = {
@@ -774,7 +776,7 @@ export type UserUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -790,7 +792,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutAccountsInput = {
@@ -806,7 +808,7 @@ export type UserCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutAccountsInput = {
@@ -822,7 +824,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutAccountsInput = {
@@ -854,7 +856,7 @@ export type UserUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutAccountsInput = {
@@ -870,7 +872,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutRoleInput = {
@@ -886,7 +888,7 @@ export type UserCreateWithoutRoleInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutRoleInput = {
@@ -902,7 +904,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutRoleInput = {
@@ -958,7 +960,7 @@ export type UserCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutUserProclamationsInput = {
@@ -974,7 +976,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutUserProclamationsInput = {
@@ -1006,7 +1008,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
@@ -1022,7 +1024,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutLicenseKeysInput = {
@@ -1038,7 +1040,7 @@ export type UserCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutLicenseKeysInput = {
@@ -1054,7 +1056,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutLicenseKeysInput = {
@@ -1086,7 +1088,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
@@ -1102,10 +1104,10 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutScheduleInput = {
export type UserCreateWithoutSchedulesInput = {
id: string
name: string
email: string
@@ -1121,7 +1123,7 @@ export type UserCreateWithoutScheduleInput = {
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutScheduleInput = {
export type UserUncheckedCreateWithoutSchedulesInput = {
id: string
name: string
email: string
@@ -1137,23 +1139,23 @@ export type UserUncheckedCreateWithoutScheduleInput = {
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutScheduleInput = {
export type UserCreateOrConnectWithoutSchedulesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
create: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
}
export type UserUpsertWithoutScheduleInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
export type UserUpsertWithoutSchedulesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutSchedulesInput, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutScheduleInput = {
export type UserUpdateToOneWithWhereWithoutSchedulesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
data: Prisma.XOR<Prisma.UserUpdateWithoutSchedulesInput, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
}
export type UserUpdateWithoutScheduleInput = {
export type UserUpdateWithoutSchedulesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
@@ -1169,7 +1171,7 @@ export type UserUpdateWithoutScheduleInput = {
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutScheduleInput = {
export type UserUncheckedUpdateWithoutSchedulesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
@@ -1208,7 +1210,7 @@ export type UserUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutRoleInput = {
@@ -1224,7 +1226,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateManyWithoutRoleInput = {
@@ -1248,6 +1250,7 @@ export type UserCountOutputType = {
profiles: number
licenseKeys: number
userProclamations: number
schedules: number
}
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1256,6 +1259,7 @@ export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.I
profiles?: boolean | UserCountOutputTypeCountProfilesArgs
licenseKeys?: boolean | UserCountOutputTypeCountLicenseKeysArgs
userProclamations?: boolean | UserCountOutputTypeCountUserProclamationsArgs
schedules?: boolean | UserCountOutputTypeCountSchedulesArgs
}
/**
@@ -1303,6 +1307,13 @@ export type UserCountOutputTypeCountUserProclamationsArgs<ExtArgs extends runtim
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<{
id?: boolean
@@ -1319,7 +1330,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
schedules?: boolean | Prisma.User$schedulesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -1366,7 +1377,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
schedules?: boolean | Prisma.User$schedulesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1385,7 +1396,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles: Prisma.$ProfilePayload<ExtArgs>[]
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
schedule: Prisma.$SchedulePayload<ExtArgs> | null
schedules: Prisma.$SchedulePayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1796,7 +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>
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>
schedule<T extends Prisma.User$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$scheduleArgs<ExtArgs>>): Prisma.Prisma__ScheduleClient<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
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.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -2374,9 +2385,9 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
}
/**
* User.schedule
* User.schedules
*/
export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
export type User$schedulesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Schedule
*/
@@ -2390,6 +2401,11 @@ export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalA
*/
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[]
}
/**
+146
View File
@@ -3,11 +3,17 @@ import {
blockDuration,
blockSegments,
findOverlap,
findScheduleOverlap,
fiveMinuteOptions,
formatTime,
hmToMinutes,
isOvernight,
parseTimeInput,
snapMinutes,
toHm,
validateBlock,
validateBlockShape,
validateSchedule,
type BlockInput,
type BlockSibling
} from './schedule-utils';
@@ -44,6 +50,52 @@ describe('toHm', () => {
});
});
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);
@@ -233,3 +285,97 @@ describe('validateBlock', () => {
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();
});
});
+237
View File
@@ -23,6 +23,187 @@ export type BlockInput = {
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;
}
@@ -45,6 +226,62 @@ export function toHm(minutes: number): string {
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;
+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);
});
});
+108 -55
View File
@@ -2,27 +2,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
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()
}
delete: vi.fn(),
deleteMany: vi.fn()
},
blockProclamations: { createMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
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,
@@ -34,52 +47,95 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateSchedule', () => {
describe('getOrCreateScheduleForLicense', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
const result = await getOrCreateSchedule('u1');
const result = await getOrCreateScheduleForLicense('lk1');
expect(result).toEqual({ id: 's1', userId: 'u1' });
expect(result).toEqual({ id: 's1', licenseKeyId: 'lk1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates the singleton schedule when missing', async () => {
it('creates a schedule keyed by license when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
scheduleCreate.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
await getOrCreateSchedule('u1');
await getOrCreateScheduleForLicense('lk1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
expect(scheduleCreate).toHaveBeenCalledWith({ data: { licenseKeyId: 'lk1' } });
});
});
describe('listBlocks', () => {
it('scopes to the user and includes proclamations with frequencies', async () => {
describe('listBlocksForLicense', () => {
it('scopes to the license key', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocks('u1');
await listBlocksForLicense('lk1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { userId: 'u1' } },
orderBy: { startMinutes: 'asc' },
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', userId: 'u1' });
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('u1', {
const result = await createBlock('lk1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
@@ -88,9 +144,10 @@ describe('createBlock', () => {
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
day: null,
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
dynamicAudio: true,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
@@ -101,37 +158,21 @@ describe('createBlock', () => {
});
});
it('persists the dynamic audio flag', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
it('rejects when no type is set', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
await createBlock('u1', { ...validInput, dynamicAudio: true });
const result = await createBlock('lk1', validInput);
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: true })
})
);
});
it('rejects a fifth block without creating', async () => {
blockFindMany.mockResolvedValue([
{ id: 'a', startMinutes: 0, endMinutes: 60 },
{ id: 'b', startMinutes: 60, endMinutes: 120 },
{ id: 'c', startMinutes: 120, endMinutes: 180 },
{ id: 'd', startMinutes: 180, endMinutes: 240 }
]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
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', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
blockFindMany.mockResolvedValue([
{ id: 'a', day: null, startMinutes: 7 * 60, endMinutes: 9 * 60, proclamations: [] }
]);
const result = await createBlock('u1', validInput);
const result = await createBlock('lk1', validInput);
expect(result).toEqual({
ok: false,
@@ -139,13 +180,22 @@ describe('createBlock', () => {
});
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 user', async () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('u1', 'missing', validInput);
const result = await updateBlock('lk1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
@@ -153,22 +203,25 @@ describe('updateBlock', () => {
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('u1', 'b1', validInput);
const result = await updateBlock('lk1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockFindMany).toHaveBeenCalledWith({
where: { scheduleId: 's1', id: { not: 'b1' } },
select: { id: true, startMinutes: true, endMinutes: true }
});
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: false,
dynamicAudio: true,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
@@ -179,10 +232,10 @@ describe('updateBlock', () => {
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
it('returns not found when the block does not belong to the license', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('u1', 'missing');
const result = await deleteBlock('lk1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
@@ -192,7 +245,7 @@ describe('deleteBlock', () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('u1', 'b1');
const result = await deleteBlock('lk1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
+425 -44
View File
@@ -1,73 +1,288 @@
// Schedule service layer. Owns the database orchestration and enforcement of
// the Block business rules (at most 4 per user, at least one Proclamation,
// no overnight ranges, no overlaps). Pure validation lives in
// `$lib/schedule-utils` so the client modal can reuse it.
// 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 { validateBlock, type BlockInput } from '$lib/schedule-utils';
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,
validateBlock
findScheduleOverlap,
validateBlock,
validateBlockAgainstSchedule,
validateBlockShape,
validateSchedule
} from '$lib/schedule-utils';
export type { BlockInput, BlockSibling, ValidationResult } 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))];
}
/** Ensure the user has a Schedule row, creating the singleton on first use. */
export async function getOrCreateSchedule(userId: string) {
const existing = await prisma.schedule.findUnique({ where: { userId } });
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: { userId } });
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
};
}
/**
* The user's Blocks with their selected Proclamations (and frequencies for
* canonical-id mapping), ordered by start time.
* 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 listBlocks(userId: string) {
return prisma.block.findMany({
where: { schedule: { userId } },
orderBy: { startMinutes: 'asc' },
include: {
proclamations: {
orderBy: { sortOrder: 'asc' },
include: {
proclamation: { include: { solfeggioFrequency: true } }
}
}
}
});
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.' };
}
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
const schedule = await getOrCreateSchedule(userId);
const siblings = await prisma.block.findMany({
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
if (schedule.type === type) return { ok: true };
const existing = await prisma.block.findMany({
where: { scheduleId: schedule.id },
select: { id: true, startMinutes: true, endMinutes: true }
include: { proclamations: { orderBy: { sortOrder: 'asc' } } }
});
const validation = validateBlock(input, siblings, { isCreate: true });
// 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: input.dynamicAudio === true,
dynamicAudio: dynamicAudioOrDefault(input),
proclamations: {
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
@@ -76,36 +291,36 @@ export async function createBlock(userId: string, input: BlockInput): Promise<Bl
}
}
});
return { ok: true, id: block.id };
}
export async function updateBlock(
userId: string,
licenseKeyId: string,
blockId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
where: { id: blockId, schedule: { licenseKeyId } },
select: { id: true, scheduleId: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
const siblings = await prisma.block.findMany({
where: { scheduleId: block.scheduleId, id: { not: blockId } },
select: { id: true, startMinutes: true, endMinutes: true }
});
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 validation = validateBlock(input, siblings);
if (!validation.ok) return validation;
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: input.dynamicAudio === true,
dynamicAudio: dynamicAudioOrDefault(input),
proclamations: {
deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({
@@ -115,13 +330,15 @@ export async function updateBlock(
}
}
});
return { ok: true, id: blockId };
}
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
export async function deleteBlock(
licenseKeyId: string,
blockId: string
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
where: { id: blockId, schedule: { licenseKeyId } },
select: { id: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
@@ -129,3 +346,167 @@ export async function deleteBlock(userId: string, blockId: string): Promise<Bloc
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', () => {
it('returns only key and email_address (levels removed)', () => {
it('returns key, email_address, and the advisory systemNameStatus', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com'
emailAddress: 'user@example.com',
systemNameStatus: 'unchanged'
});
expect(result).toEqual({
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');
});
+75 -3
View File
@@ -1,4 +1,5 @@
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
import { createHash } from 'node:crypto';
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
@@ -119,13 +120,20 @@ export function serializeGroup(g: { id: number; name: string; sortOrder: number
export type V2License = {
key: string;
email_address: string;
systemNameStatus: string;
};
/** Build the POST /license response. */
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License {
/** Build the POST /license response. `systemNameStatus` is advisory: the Kiosk
* treats "ok"/"unchanged" as synced and reverts on "conflict"/"invalid". */
export function serializeLicense(opts: {
key: string;
emailAddress: string;
systemNameStatus: string;
}): V2License {
return {
key: opts.key,
email_address: opts.emailAddress
email_address: opts.emailAddress,
systemNameStatus: opts.systemNameStatus
};
}
@@ -183,3 +191,67 @@ export function serializeScheduleBlock(
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"
>
<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">Status</th>
<th class="px-6 py-3 font-semibold">Created</th>
@@ -53,6 +54,13 @@
<tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)}
<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">
{#if key.active}
@@ -104,6 +112,9 @@
{/if}
</span>
</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"
>Created {formatDate(key.createdAt)}</span
>
@@ -1,4 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types';
@@ -100,7 +101,7 @@
</div>
{/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="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
@@ -206,6 +207,7 @@
<form
method="POST"
action={editingAddressId ? '?/updateAddress' : '?/addAddress'}
use:enhance
class="mt-5 flex flex-col gap-4"
>
{#if editingAddressId}
@@ -314,8 +316,10 @@
<form
method="POST"
action="?/deleteAddress"
onsubmit={(event) => {
if (!confirm('Delete this address?')) event.preventDefault();
use:enhance={({ cancel }) => {
if (!confirm('Delete this address?')) {
cancel();
}
}}
>
<input type="hidden" name="id" value={address.id} />
@@ -11,6 +11,7 @@
-->
<script lang="ts">
import { enhance } from '$app/forms';
import Window from '$lib/components/containers/Window.svelte';
import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types';
@@ -42,7 +43,7 @@
</script>
<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">
Please finish setting up your profile
</h1>
@@ -1,11 +1,22 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
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) {
@@ -19,16 +30,29 @@ function parseInput(formData: FormData) {
}
}
const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
return { day, startMinutes, endMinutes, proclamationIds, dynamicAudio };
}
export const load: PageServerLoad = async ({ locals }) => {
/** 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 [blocks, categories, groups, proclamations] = await Promise.all([
listBlocks(locals.user.id),
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' }]
@@ -45,8 +69,41 @@ export const load: PageServerLoad = async ({ locals }) => {
})
]);
const blockViews = blocks.map((block) => ({
// 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,
@@ -57,15 +114,45 @@ export const load: PageServerLoad = async ({ locals }) => {
groupId: entry.proclamation.groupId
}))
}));
}
}
return { blocks: blockViews, categories, groups, proclamations };
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 input = parseInput(await request.formData());
const result = await createBlock(locals.user.id, input);
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 };
},
@@ -73,19 +160,57 @@ export const actions: Actions = {
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(locals.user.id, id, parseInput(formData));
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 id = (await request.formData()).get('id')?.toString();
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(locals.user.id, id);
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
};
}
};
@@ -1,32 +1,138 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
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(false);
let successMessage = $state('');
let initialDynamicAudio = $state(true);
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function openCreate(startMinutes: number, endMinutes: number) {
// 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 = false;
initialDynamicAudio = true;
modalOpen = true;
}
function openEdit(block: {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
@@ -34,9 +140,10 @@
}) {
modalMode = 'edit';
editingId = block.id;
modalDay = block.day;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id);
initialIds = block.proclamations.map((p) => p.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true;
}
@@ -47,12 +154,62 @@
function handleSaved() {
modalOpen = false;
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
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>
@@ -60,14 +217,23 @@
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build your daily listening schedule. Up to 4 blocks per day.
Build a listening schedule for a system. Up to 4 blocks per day.
</p>
</div>
<span
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
{#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)}
>
{data.blocks.length} of 4 blocks
</span>
{#each data.licenses as license (license.id)}
<option value={license.id}>{license.systemName ?? license.key}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if successMessage}
@@ -77,15 +243,120 @@
{successMessage}
</div>
{/if}
<div class="mt-6">
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
{#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}
@@ -93,8 +364,152 @@
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={data.blocks}
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}
@@ -6,25 +6,40 @@ vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }
proclamation: { findMany: vi.fn() },
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() },
schedule: { findMany: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
listBlocks: vi.fn(),
getScheduleForLicense: vi.fn(),
setScheduleType: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn()
deleteBlock: vi.fn(),
copySchedule: vi.fn()
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
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 listBlocksMock = listBlocks 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);
@@ -37,18 +52,25 @@ beforeEach(() => {
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
listBlocksMock.mockResolvedValue([]);
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() } as never)).rejects.toMatchObject({
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 } as never);
await load({ locals, url: new URL('http://x/') } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
@@ -58,9 +80,13 @@ describe('schedule load', () => {
});
it('maps blocks to title/category/group only', async () => {
listBlocksMock.mockResolvedValue([
getScheduleMock.mockResolvedValue({
id: 's1',
type: 'daily',
blocks: [
{
id: 'b1',
day: null,
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
@@ -76,16 +102,15 @@ describe('schedule load', () => {
}
]
}
]);
]
});
const result = (await load({ locals } as never)) as {
blocks: {
id: string;
dynamicAudio: boolean;
proclamations: Record<string, unknown>[];
}[];
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',
@@ -112,6 +137,7 @@ describe('schedule actions', () => {
const result = await actions.createBlock!({
request: createFormRequest({
licenseKeyId: 'lk1',
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
@@ -131,6 +157,8 @@ describe('schedule actions', () => {
const result = await actions.createBlock!({
request: createFormRequest({
licenseKeyId: 'lk1',
day: 'mon',
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']),
@@ -140,7 +168,8 @@ describe('schedule actions', () => {
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
expect(createBlockMock).toHaveBeenCalledWith('lk1', {
day: 'mon',
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2'],
@@ -148,27 +177,23 @@ describe('schedule actions', () => {
});
});
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
it('setType requiring confirmation returns the confirm error', async () => {
setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' });
await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1'])
}),
const result = await actions.setType!({
request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }),
locals
} as never);
expect(createBlockMock).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ dynamicAudio: false })
);
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({}),
request: createFormRequest({ licenseKeyId: 'lk1' }),
locals
} as never);
@@ -178,4 +203,76 @@ describe('schedule actions', () => {
}
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[] = [
{ label: 'Key', value: (r) => r.key },
{ label: 'System Name', value: (r) => (r.systemName as string | null) ?? 'Unnamed system' },
{
label: 'User',
value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? ''
+21 -1
View File
@@ -4,6 +4,11 @@ import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
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}$/;
@@ -50,6 +55,20 @@ export const POST: RequestHandler = async ({ request }) => {
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
// timestamp should not fail an otherwise valid validation.
try {
@@ -64,7 +83,8 @@ export const POST: RequestHandler = async ({ request }) => {
return jsonOk(
serializeLicense({
key: storedKey.key,
emailAddress: email
emailAddress: email,
systemNameStatus
})
);
};
+91 -2
View File
@@ -43,7 +43,8 @@ const storedKey = {
id: 'key-1',
key: 'ABCD-EFGH-IJKL-MNOP',
active: true,
userId: 'user-1'
userId: 'user-1',
systemName: null
};
describe('POST /api/v2/license', () => {
@@ -64,7 +65,8 @@ describe('POST /api/v2/license', () => {
const body = (await res.json()) as Record<string, unknown>;
expect(body).toEqual({
key: storedKey.key,
email_address: 'user@example.com'
email_address: 'user@example.com',
systemNameStatus: 'unchanged'
});
expect(Object.keys(body)).not.toContain('subscription_level');
expect(mockKeyUpdate).toHaveBeenCalledWith({
@@ -147,3 +149,90 @@ describe('POST /api/v2/license', () => {
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();
});
});
+15 -11
View File
@@ -3,16 +3,24 @@ 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 { listBlocks } from '$lib/server/schedule';
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
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);
@@ -50,14 +58,10 @@ export const GET: RequestHandler = async ({ request, url }) => {
throw error(400, "this key doesn't appear to belong to you");
}
const blocks = await listBlocks(user.id);
const schedule = await getScheduleForLicense(storedKey.id);
const type = scheduleType(schedule?.type);
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
const payload = buildSchedulePayload(type, blocks);
const serialized = blocks.map((block) =>
serializeScheduleBlock(
block,
block.proclamations.map((entry) => entry.proclamation.id)
)
);
return jsonOk({ email, blocks: serialized });
return jsonOk({ email, ...payload });
};
+42 -17
View File
@@ -3,19 +3,24 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: { findMany: vi.fn() },
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 blockFindMany = prisma.block.findMany 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');
@@ -29,7 +34,8 @@ const KEY = 'AAAA-BBBB-CCCC-DDDD';
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
blockFindMany.mockResolvedValue([]);
getScheduleMock.mockResolvedValue(null);
toInputMock.mockReturnValue([]);
});
describe('GET /api/v2/schedule auth', () => {
@@ -50,7 +56,7 @@ describe('GET /api/v2/schedule auth', () => {
});
it('rejects an unknown user', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
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({
@@ -60,7 +66,12 @@ describe('GET /api/v2/schedule auth', () => {
});
it('rejects a key that does not belong to the email account', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
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({
@@ -88,28 +99,31 @@ describe('GET /api/v2/schedule auth', () => {
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' });
blockFindMany.mockResolvedValue([
getScheduleMock.mockResolvedValue({ type: 'daily' });
toInputMock.mockReturnValue([
{
id: 'block-1',
day: null,
startMinutes: 21 * 60,
endMinutes: 9 * 60,
dynamicAudio: true,
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
proclamationIds: ['p1', 'p2']
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
email: 'jane@example.com',
blocks: [
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',
@@ -121,19 +135,26 @@ describe('GET /api/v2/schedule success', () => {
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({ key: KEY, userId: 'user-1', email: null });
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
getScheduleMock.mockResolvedValue({ type: 'daily' });
toInputMock.mockReturnValue([
{
id: 'block-1',
day: null,
startMinutes: 0,
endMinutes: 60,
proclamations: [{ proclamation: { id: 'p1' } }]
dynamicAudio: false,
proclamationIds: ['p1']
}
]);
@@ -157,6 +178,7 @@ describe('GET /api/v2/schedule success', () => {
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'
@@ -166,6 +188,9 @@ describe('GET /api/v2/schedule success', () => {
const response = await call({ email: 'jane@example.com', key: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
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">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
import Window from '$lib/components/containers/Window.svelte';
@@ -24,7 +25,7 @@
</div>
{/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>
<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.
+2 -1
View File
@@ -10,7 +10,8 @@
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({
email,
password,
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Window from '$lib/components/containers/Window.svelte';
import { authClient } from '$lib/auth-client';
import { resolve } from '$app/paths';
@@ -32,7 +33,7 @@
</div>
{/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>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import Window from '$lib/components/containers/Window.svelte';
@@ -15,7 +16,7 @@
</div>
{/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">
{data.invite ? 'Set Your Password' : 'Reset Password'}
</h1>