diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index a9b65ad..720aa9f 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -6,7 +6,7 @@ on: jobs: build-and-push: - runs-on: portal-builder + runs-on: orion-lxc steps: - name: Checkout @@ -37,7 +37,7 @@ jobs: migrate: needs: build-and-push - runs-on: portal-builder + runs-on: orion-lxc steps: - name: Checkout diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index b5694ff..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -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": ["/**"], - "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/*" - } - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0967ef4..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index b6e7737..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -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:.*$" - } - } - } - ] -} diff --git a/docs/fixtures/schedule-adjacent.json b/docs/fixtures/schedule-adjacent.json new file mode 100644 index 0000000..abfef1d --- /dev/null +++ b/docs/fixtures/schedule-adjacent.json @@ -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" + ] + } + ] +} diff --git a/docs/fixtures/schedule-daily.json b/docs/fixtures/schedule-daily.json new file mode 100644 index 0000000..b59deaf --- /dev/null +++ b/docs/fixtures/schedule-daily.json @@ -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"] + } + ] +} diff --git a/docs/fixtures/schedule-dynamic-audio.json b/docs/fixtures/schedule-dynamic-audio.json new file mode 100644 index 0000000..6568d06 --- /dev/null +++ b/docs/fixtures/schedule-dynamic-audio.json @@ -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"] + } + ] +} diff --git a/docs/fixtures/schedule-legacy-v2.json b/docs/fixtures/schedule-legacy-v2.json new file mode 100644 index 0000000..10b9190 --- /dev/null +++ b/docs/fixtures/schedule-legacy-v2.json @@ -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"] + } + ] +} diff --git a/docs/fixtures/schedule-midnight-crossing.json b/docs/fixtures/schedule-midnight-crossing.json new file mode 100644 index 0000000..8ab4543 --- /dev/null +++ b/docs/fixtures/schedule-midnight-crossing.json @@ -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"] + } + ] +} diff --git a/docs/fixtures/schedule-none.json b/docs/fixtures/schedule-none.json new file mode 100644 index 0000000..4121f83 --- /dev/null +++ b/docs/fixtures/schedule-none.json @@ -0,0 +1,7 @@ +{ + "version": "sched-none-0001", + "type": null, + "daily": [], + "weekly": {}, + "blocks": [] +} diff --git a/docs/fixtures/schedule-portal-daily.json b/docs/fixtures/schedule-portal-daily.json new file mode 100644 index 0000000..d9a0500 --- /dev/null +++ b/docs/fixtures/schedule-portal-daily.json @@ -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"] + } + ] +} diff --git a/docs/fixtures/schedule-portal-weekly.json b/docs/fixtures/schedule-portal-weekly.json new file mode 100644 index 0000000..d0eed98 --- /dev/null +++ b/docs/fixtures/schedule-portal-weekly.json @@ -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": [] +} diff --git a/docs/fixtures/schedule-weekly.json b/docs/fixtures/schedule-weekly.json new file mode 100644 index 0000000..d71b65a --- /dev/null +++ b/docs/fixtures/schedule-weekly.json @@ -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": [] +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3afea31..1d70c1a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/docs/schedule-api-contract.md b/docs/schedule-api-contract.md new file mode 100644 index 0000000..258dce0 --- /dev/null +++ b/docs/schedule-api-contract.md @@ -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=&licenseKey=` + (`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": "" }`. +- **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": "", + "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": ""}`). +- 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: ""` 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=&licenseKey=` 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. diff --git a/docs/scheduler-overhaul-phase5-results.md b/docs/scheduler-overhaul-phase5-results.md new file mode 100644 index 0000000..c959a99 --- /dev/null +++ b/docs/scheduler-overhaul-phase5-results.md @@ -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`. diff --git a/docs/scheduler-overhaul-release-notes.md b/docs/scheduler-overhaul-release-notes.md new file mode 100644 index 0000000..fa15185 --- /dev/null +++ b/docs/scheduler-overhaul-release-notes.md @@ -0,0 +1,164 @@ +# Scheduler Overhaul — Release Notes + +Status: prepared for rollout. These are **instructions only**; nothing here has +been executed. The contract lives at `docs/schedule-api-contract.md` (kept +byte-identical in both repos). + +This document is kept **byte-identical** in both repositories: + +- `radiantwave` (C++/SDL3 Kiosk app) — `docs/scheduler-overhaul-release-notes.md` +- `portal` (SvelteKit/Node) — `docs/scheduler-overhaul-release-notes.md` + +--- + +## What changed + +### Portal + +- **Schedules are assigned to a License Key**, not a user. `Schedule` gained + `licenseKeyId` (unique, nullable) and `type` (`daily` | `weekly` | null); + `Block` gained `day` (null for daily, `mon`..`sun` for weekly) and its + `dynamicAudio` default became `true`. +- **New Kiosk endpoint** `GET /api/v2/licenses/{licenseKey}/schedule` returns the + target payload (`version`, `type`, `daily`, `weekly`, `blocks`) with an `ETag` + derived from `version`, supports conditional GET (`304`), and is `404` for an + unknown key. +- **License validation** (`POST /api/v2/license`) accepts an optional + `systemName` and returns an additive `systemNameStatus` + (`ok` | `unchanged` | `conflict` | `invalid`). Validation always succeeds; a + non-`ok` name status is advisory. +- **New Kiosk endpoint** `PUT /api/v2/licenses/{licenseKey}/system-name` + (`200` / `400 invalid_name` / `404 unknown_license` / `409 name_taken`, + idempotent). +- **System Name** is a human-readable label for a License Key. It is set only + by the Kiosk and shown read-only in the Portal (dashboard and admin lists), + falling back to displaying the License Key. There is no Portal rename path. + Names are unique per account, compared case-insensitively, trimmed, 1–40 + characters. +- **Schedule editor** (protected user route `(protected)/(user)/schedule`): + a per-system picker labelled by System Name, a Daily/Weekly type selector with + confirmation when switching a populated schedule (daily→weekly prefills all + seven days; weekly→daily and clearing start empty), per-day weekly editing, + AM/PM `TimeInput` (5-minute suggestions plus manual minute entry), midnight + crossing labelled "ends next day", an on-by-default dynamic-audio toggle, gap + helper text, and an informational note that times run in the Kiosk's own + timezone (browser timezone fallback; no external GeoIP service). +- **Copy to…** copies one system's Schedule to one or more other systems in the + same account, by value, in a single database transaction (all or none). Target + Blocks get fresh IDs so target `version`/ETag changes; `dynamicAudio` and + Proclamation order are preserved. Overwriting a target that already has a + schedule requires explicit confirmation. Copies are independent. +- **OpenAPI** (`docs/openapi.yaml`) documents the new endpoints and payloads. + +### Kiosk (`radiantwave`) + +- **12-hour AM/PM** time display everywhere the Kiosk shows a time; storage and + parsing remain 24-hour `HH:MM`. The Date Format setting is unchanged. +- **System Settings** menu contains Email Address, License Key, **System Name**, + **Schedule** (on/off), WiFi Setup, Time Zone Setup, and Date Format. The list + is data-driven. +- **System Name** entry (trimmed, 1–40), sent on license validation and via + `PUT /api/v2/licenses/{key}/system-name`. Conflicts/invalid names are reverted + locally with a user-facing message; network failures keep the new name locally + and retry (pending-sync flag) on boot, on validation, and on a timer. +- **Schedule client**: conditional GET (`If-None-Match`) of + `GET /api/v2/licenses/{key}/schedule`, on boot, on network-up, and every + 5 minutes. The last payload is cached to + `~/.local/radiantwave/schedule_cache.json` with an **atomic write** (temp file + - rename); a corrupt cache is ignored. Offline uses the cache; no cache and no + network behaves as schedules-off. +- **Schedule engine**: given the payload, the local wall-clock time in the + Kiosk's Timezone setting, and the weekday, picks the active Block. Half-open + `[start, end)` boundaries; midnight-crossing Blocks belong to their start day + (weekly Sunday spills into Monday); invalid Blocks are skipped with a warning + and treated as gaps. Active Block → its Proclamations; `dynamicAudio` true + forces dynamic audio, false uses the locally chosen audio; gaps / no schedule + / toggle off use local selection exactly as before. +- **Schedule toggle** in System Settings (`proclamation.schedule_enabled`, + default on, Kiosk-local, not synced). Off takes no action but the cache keeps + updating. + +--- + +## Migration notes + +- **Portal migrations are additive `up` migrations** and are safe to deploy + while old Kiosks keep running: + - `20260929120000_add_license_key_system_name` — adds + `LicenseKey.systemName` / `systemNameLower` and a unique index on + `(userId, systemNameLower)`. + - `20260929130000_move_schedule_to_license_key` — adds `Schedule.licenseKeyId` + / `type` and `Block.day`, changes the `dynamicAudio` default to `true`, + makes the legacy `Schedule.userId` optional/non-unique, and backfills each + existing per-user Schedule to that account's **oldest** License Key as a + **daily** schedule. Schedules that cannot be mapped (no owning user/license) + are retained with `licenseKeyId NULL` and reported via a migration `NOTICE` + — they are never silently dropped. +- **Existing (per-user) schedules survive the `up` migration** and are mapped to + a License Key on the same account. +- **⚠ The `down` path is lossy for data created under the new model.** Back up + `Schedule`, `Block`, and `BlockProclamations` before any down-migration. + Rolling back past the Phase 3 migration deletes per-License-Key schedules (see + the contract's Open item 22). +- No Portal schema change affects Kiosks (the Kiosk stores nothing new on the + server beyond the System Name label and its schedule mapping). + +--- + +## Recommended rollout order + +Each step is independent; do not skip the verification gates. Do **not** tag, +push, or deploy as part of reading this document — these are instructions. + +### Step 1 — Run Portal migrations and deploy the Portal + +1. Back up the Portal database (at minimum the `Schedule`, `Block`, + `BlockProclamations`, and `LicenseKey` tables). +2. Apply migrations: `npm run deploy` (which runs `npx prisma migrate deploy`). +3. Confirm `npx prisma migrate status` reports the database is up to date and + review the migration logs for the unmappable-schedule `NOTICE`, if any. +4. Deploy the Portal build (the existing CI/deploy path). + +**Rollback (Step 1):** redeploy the previous Portal build. Because the `up` +migrations are additive, the old Portal continues to work against the migrated +schema; no schema rollback is required to redeploy the old Portal. If a schema +rollback is unavoidable, restore from the Step-1 backup (the `down` path is +lossy — see Migration notes). + +### Step 2 — Verify old Kiosks still work + +1. Against the deployed Portal, confirm an **old Kiosk** (no `systemName`, using + the legacy endpoints) still: + - validates via `POST /api/v2/license` (no `systemName`) and receives `200`; + - reads `GET /api/v2/schedule?email=…&licenseKey=…` and still finds its + `blocks`; + - is unaffected by the new fields, and its stored System Name (if any) is + never cleared. +2. Confirm the Portal dashboard/admin show System Names read-only. + +**Rollback (Step 2):** none needed — this step is read-only verification. If old +Kiosks misbehave, roll back the Portal (Step 1 rollback) before proceeding. + +### Step 3 — Release the Kiosk build + +1. Build and validate the Kiosk through the normal path: tag → CI → the + auto-update channel. (Tagging triggers production CI; do it only when ready.) +2. Confirm the released build reports the expected version and updates in place. +3. Spot-check a device after update: 12-hour times, System Settings contents, + schedule on/off, and that an assigned Schedule is fetched and applied. + +**Rollback (Step 3):** use the existing auto-update rollback (re-publish or +pin the previous known-good Kiosk version). The Kiosk tolerates both old and new +Portal payloads, so a Portal rollback alone does not require a Kiosk rollback, +and vice versa. + +--- + +## Known limitations + +- Kiosk API endpoints are **unauthenticated** (key-only), mitigated by a + 10 requests/min/IP rate limit. Authentication is out of scope for this + overhaul. +- The Phase 3 migration's `down` path is lossy for new-model schedules + (see Migration notes and contract Open item 22). +- The Portal does not offer a rename path for System Names by design. diff --git a/prisma/migrations/20260929120000_add_license_key_system_name/migration.sql b/prisma/migrations/20260929120000_add_license_key_system_name/migration.sql new file mode 100644 index 0000000..c8d0f63 --- /dev/null +++ b/prisma/migrations/20260929120000_add_license_key_system_name/migration.sql @@ -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"); diff --git a/prisma/migrations/20260929130000_move_schedule_to_license_key/migration.sql b/prisma/migrations/20260929130000_move_schedule_to_license_key/migration.sql new file mode 100644 index 0000000..c6338c1 --- /dev/null +++ b/prisma/migrations/20260929130000_move_schedule_to_license_key/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4243932..bc70a88 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) - blocks Block[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + licenseKeyId String? @unique + licenseKey LicenseKey? @relation(fields: [licenseKeyId], references: [id], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + type String? + blocks Block[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) } -// A single 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 diff --git a/scripts/p5-http-scenarios.ts b/scripts/p5-http-scenarios.ts new file mode 100644 index 0000000..e4366fc --- /dev/null +++ b/scripts/p5-http-scenarios.ts @@ -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 = {}) { + 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); +}); diff --git a/scripts/p5-proclamations.ts b/scripts/p5-proclamations.ts new file mode 100644 index 0000000..fe50bd5 --- /dev/null +++ b/scripts/p5-proclamations.ts @@ -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: `

${name} content

`, + 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); +}); diff --git a/scripts/p5-seed.ts b/scripts/p5-seed.ts new file mode 100644 index 0000000..dfb0112 --- /dev/null +++ b/scripts/p5-seed.ts @@ -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); +}); diff --git a/scripts/p5-snapshot.ts b/scripts/p5-snapshot.ts new file mode 100644 index 0000000..86b11fd --- /dev/null +++ b/scripts/p5-snapshot.ts @@ -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(); diff --git a/src/lib/components/TimeInput.svelte b/src/lib/components/TimeInput.svelte new file mode 100644 index 0000000..3bbbd9f --- /dev/null +++ b/src/lib/components/TimeInput.svelte @@ -0,0 +1,103 @@ + + +
+ {#if label} + + {/if} + + + {#each options as minutes (minutes)} + + {/each} + + {#if error} + + {/if} +
diff --git a/src/lib/components/TimeInput.svelte.spec.ts b/src/lib/components/TimeInput.svelte.spec.ts new file mode 100644 index 0000000..2feff75 --- /dev/null +++ b/src/lib/components/TimeInput.svelte.spec.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/src/lib/components/schedule/BlockModal.svelte b/src/lib/components/schedule/BlockModal.svelte index 9e1e263..d2cb4f1 100644 --- a/src/lib/components/schedule/BlockModal.svelte +++ b/src/lib/components/schedule/BlockModal.svelte @@ -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([]); - 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} {/if} + + {#if day} + + {/if} @@ -228,81 +222,19 @@ {#if step === 'details'}
-
- Start time -
- - : - - -
-
+ (startValid = valid)} + /> -
- End time -
- - : - - -
-
+ (endValid = valid)} + />
@@ -359,7 +291,7 @@

Review

{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight - ? ' (next day)' + ? ' (ends next day)' : ''}

{#if selectedTitles.length === 0} diff --git a/src/lib/components/schedule/BlockModal.svelte.spec.ts b/src/lib/components/schedule/BlockModal.svelte.spec.ts index 21531df..a07ce81 100644 --- a/src/lib/components/schedule/BlockModal.svelte.spec.ts +++ b/src/lib/components/schedule/BlockModal.svelte.spec.ts @@ -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'); }); }); diff --git a/src/lib/components/schedule/DaySchedule.svelte b/src/lib/components/schedule/DaySchedule.svelte deleted file mode 100644 index 67aea11..0000000 --- a/src/lib/components/schedule/DaySchedule.svelte +++ /dev/null @@ -1,173 +0,0 @@ - - -
- - - -
diff --git a/src/lib/components/schedule/WeekSchedule.svelte b/src/lib/components/schedule/WeekSchedule.svelte new file mode 100644 index 0000000..0c91e7b --- /dev/null +++ b/src/lib/components/schedule/WeekSchedule.svelte @@ -0,0 +1,287 @@ + + + +
+
+ +
+ +
+ +
+ + + {#each days as day (day.key)} +
+
+ {#if day.short} + {day.short} + + {:else} + {day.label} + {/if} +
+ + +
startDrag(e, day.key)} + onpointermove={moveDrag} + onpointerup={endDrag} + onpointercancel={(e) => { + if (drag && e.pointerId === drag.pointerId) drag = null; + }} + > + {#each hourMarks as hour (hour)} +
+ {/each} + + {#if preview && preview.day === day.key} +
+ {/if} + + {#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)} + + {/each} +
+
+ {/each} +
+
diff --git a/src/lib/components/schedule/WeekSchedule.svelte.spec.ts b/src/lib/components/schedule/WeekSchedule.svelte.spec.ts new file mode 100644 index 0000000..550861d --- /dev/null +++ b/src/lib/components/schedule/WeekSchedule.svelte.spec.ts @@ -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 = {}) => ({ + id: 'b1', + day: 'mon', + startMinutes: 6 * 60, + endMinutes: 8 * 60, + dynamicAudio: true, + proclamations: [{ id: 'p1', name: 'Peace' }], + ...overrides +}); + +function mount(props: Record = {}) { + 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' }); + }); +}); diff --git a/src/lib/generated/prisma/internal/class.ts b/src/lib/generated/prisma/internal/class.ts index b61ea9d..b96cda1 100644 --- a/src/lib/generated/prisma/internal/class.ts +++ b/src/lib/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.8.0", "engineVersion": "3c6e192761c0362d496ed980de936e2f3cebcd3a", "activeProvider": "postgresql", - "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/lib/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n roleId Int? @default(4) // New users default to \"User\"\n role UserRole? @relation(fields: [roleId], references: [id])\n sessions Session[]\n accounts Account[]\n profiles Profile[]\n licenseKeys LicenseKey[]\n userProclamations UserProclamations[]\n schedule Schedule?\n\n @@unique([email])\n @@map(\"user\")\n}\n\n// I chose to create a Profile table here because honestly, I didn't want to waste\n// a bunch of time trying to figure out how to customize the default Better-Auth model. \nmodel Profile {\n id String @id @default(uuid())\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n email String?\n firstName String\n lastName String\n phoneNumber String\n ghlContactId String? @map(\"ghl_contact_id\")\n addresses Address[]\n\n @@index([userId])\n @@index([email])\n}\n\nmodel Address {\n id String @id @default(uuid())\n profileId String\n profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)\n street String\n city String\n state String\n postalCode String\n country String\n type String @default(\"Mailing\")\n isDefaultMailing Boolean @default(false)\n isDefaultBilling Boolean @default(false)\n isDefaultShipping Boolean @default(false)\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel UserRole {\n id Int @id @default(autoincrement())\n name String\n users User[]\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Proclamation {\n id String @id @default(uuid())\n name String @unique\n content String\n solfeggioFrequencyId Int\n solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])\n categoryId Int\n category ProclamationCategory @relation(fields: [categoryId], references: [id])\n groupId Int\n group ProclamationGroup @relation(fields: [groupId], references: [id])\n userProclamations UserProclamations[]\n blockProclamations BlockProclamations[]\n // Legacy subscription-level FK. Retained in the database but unmanaged; the\n // FK constraint is dropped and the value is left NULL. Hidden from the client.\n subscriptionLevelId Int? @ignore\n}\n\nmodel ProclamationGroup {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\nmodel UserProclamations {\n userId String\n user User @relation(fields: [userId], references: [id])\n proclamationId String\n proclamation Proclamation @relation(fields: [proclamationId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@id([userId, proclamationId])\n}\n\nmodel SolfeggioFrequency {\n id Int @id @default(autoincrement())\n name String @unique\n value Int @unique\n proclamations Proclamation[]\n}\n\nmodel ProclamationCategory {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\n// Archive of the former child sub-categories removed when the\n// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.\n// `@@ignore` keeps it out of the generated client while Prisma remains aware\n// of the table so `migrate dev` does not report it as an untracked table.\nmodel ProclamationCategoryLegacy {\n id Int @id\n name String\n parentId Int?\n sortOrder Int @default(0)\n\n @@map(\"ProclamationCategoryLegacy\")\n @@ignore\n}\n\n// Archive of the category->subscription-level mappings for the child\n// categories removed when the category hierarchy was flattened. Kept with\n// `@@ignore` so Prisma remains aware of the table but it is excluded from the\n// generated client and Prisma's own migrations.\nmodel ProclamationCategorySubscriptionLevelLegacy {\n categoryId Int\n subscriptionLevelId Int\n\n @@id([categoryId, subscriptionLevelId])\n @@map(\"ProclamationCategorySubscriptionLevelLegacy\")\n @@ignore\n}\n\nmodel LicenseKey {\n id String @id @default(uuid())\n key String @unique\n active Boolean\n createdAt DateTime @default(now())\n lastValidatedAt DateTime?\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n email String?\n // Legacy subscription level. Retained in the database but unmanaged; hidden\n // from the client and left NULL.\n level String? @ignore\n\n @@index([userId])\n @@index([email])\n}\n\nmodel ApiKey {\n id String @id @default(cuid())\n keyPrefix String\n keyHash String @unique\n createdAt DateTime @default(now())\n expiresAt DateTime?\n revokedAt DateTime?\n}\n\n// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).\n// `solfeggioFrequencyId` is the canonical id the kiosk uses (see\n// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.\nmodel ColorPalette {\n solfeggioFrequencyId Int @id\n colors Json // [\"#RRGGBB\", ... x7]\n\n @@map(\"ColorPalette\")\n}\n\n// Append-only audit trail of database mutations. Written centrally by the\n// Prisma extension in src/lib/db.ts; read by the admin Audit Log page.\nmodel AuditLog {\n id String @id @default(uuid())\n createdAt DateTime @default(now())\n userId String?\n email String?\n roleId Int?\n roleName String?\n page String\n action String\n entity String?\n entityId String?\n before Json?\n after Json?\n ip String?\n\n @@index([createdAt])\n @@index([roleId])\n @@index([action])\n @@index([page])\n @@map(\"AuditLog\")\n}\n\n// A user's daily listening schedule. Exactly one per user (enforced by the\n// unique userId); contains 0-4 time Blocks. Times are wall-clock minutes from\n// midnight (0-1440), interpreted by the kiosk in the user's configured\n// timezone. The portal never stores a timezone.\nmodel Schedule {\n id String @id @default(uuid())\n userId String @unique\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n blocks Block[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// A single non-overlapping time range within a Schedule, plus the ordered set\n// of selected Proclamations to play during it. Overnight ranges are disallowed\n// in v1, so startMinutes is always less than endMinutes.\nmodel Block {\n id String @id @default(uuid())\n scheduleId String\n schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)\n startMinutes Int\n endMinutes Int\n // When true, the kiosk enables dynamic audio for the duration of this block,\n // overriding any audio selections made on the device.\n dynamicAudio Boolean @default(false)\n proclamations BlockProclamations[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([scheduleId])\n}\n\n// Join table: the Proclamations selected for a Block. Carries the Proclamation\n// content for playback via the API; the portal UI only ever reads\n// title/category/group.\nmodel BlockProclamations {\n blockId String\n block Block @relation(fields: [blockId], references: [id], onDelete: Cascade)\n proclamationId String\n proclamation Proclamation @relation(fields: [proclamationId], references: [id])\n sortOrder Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@id([blockId, proclamationId])\n @@index([proclamationId])\n}\n", + "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/lib/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n roleId Int? @default(4) // New users default to \"User\"\n role UserRole? @relation(fields: [roleId], references: [id])\n sessions Session[]\n accounts Account[]\n profiles Profile[]\n licenseKeys LicenseKey[]\n userProclamations UserProclamations[]\n schedules Schedule[]\n\n @@unique([email])\n @@map(\"user\")\n}\n\n// I chose to create a Profile table here because honestly, I didn't want to waste\n// a bunch of time trying to figure out how to customize the default Better-Auth model. \nmodel Profile {\n id String @id @default(uuid())\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n email String?\n firstName String\n lastName String\n phoneNumber String\n ghlContactId String? @map(\"ghl_contact_id\")\n addresses Address[]\n\n @@index([userId])\n @@index([email])\n}\n\nmodel Address {\n id String @id @default(uuid())\n profileId String\n profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)\n street String\n city String\n state String\n postalCode String\n country String\n type String @default(\"Mailing\")\n isDefaultMailing Boolean @default(false)\n isDefaultBilling Boolean @default(false)\n isDefaultShipping Boolean @default(false)\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel UserRole {\n id Int @id @default(autoincrement())\n name String\n users User[]\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel Proclamation {\n id String @id @default(uuid())\n name String @unique\n content String\n solfeggioFrequencyId Int\n solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])\n categoryId Int\n category ProclamationCategory @relation(fields: [categoryId], references: [id])\n groupId Int\n group ProclamationGroup @relation(fields: [groupId], references: [id])\n userProclamations UserProclamations[]\n blockProclamations BlockProclamations[]\n // Legacy subscription-level FK. Retained in the database but unmanaged; the\n // FK constraint is dropped and the value is left NULL. Hidden from the client.\n subscriptionLevelId Int? @ignore\n}\n\nmodel ProclamationGroup {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\nmodel UserProclamations {\n userId String\n user User @relation(fields: [userId], references: [id])\n proclamationId String\n proclamation Proclamation @relation(fields: [proclamationId], references: [id])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@id([userId, proclamationId])\n}\n\nmodel SolfeggioFrequency {\n id Int @id @default(autoincrement())\n name String @unique\n value Int @unique\n proclamations Proclamation[]\n}\n\nmodel ProclamationCategory {\n id Int @id @default(autoincrement())\n name String @unique\n sortOrder Int @default(0)\n proclamations Proclamation[]\n}\n\n// Archive of the former child sub-categories removed when the\n// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.\n// `@@ignore` keeps it out of the generated client while Prisma remains aware\n// of the table so `migrate dev` does not report it as an untracked table.\nmodel ProclamationCategoryLegacy {\n id Int @id\n name String\n parentId Int?\n sortOrder Int @default(0)\n\n @@map(\"ProclamationCategoryLegacy\")\n @@ignore\n}\n\n// Archive of the category->subscription-level mappings for the child\n// categories removed when the category hierarchy was flattened. Kept with\n// `@@ignore` so Prisma remains aware of the table but it is excluded from the\n// generated client and Prisma's own migrations.\nmodel ProclamationCategorySubscriptionLevelLegacy {\n categoryId Int\n subscriptionLevelId Int\n\n @@id([categoryId, subscriptionLevelId])\n @@map(\"ProclamationCategorySubscriptionLevelLegacy\")\n @@ignore\n}\n\nmodel LicenseKey {\n id String @id @default(uuid())\n key String @unique\n active Boolean\n createdAt DateTime @default(now())\n lastValidatedAt DateTime?\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n email String?\n schedule Schedule?\n // Human-readable label set only by the Kiosk (e.g. \"Living Room\"). Trimmed,\n // 1-40 chars. Never a reference or foreign key; the License Key is the only\n // identifier. `systemNameLower` is a case-folded copy used to enforce\n // per-account, case-insensitive uniqueness when non-null.\n systemName String?\n systemNameLower String?\n // Legacy subscription level. Retained in the database but unmanaged; hidden\n // from the client and left NULL.\n level String? @ignore\n\n @@unique([userId, systemNameLower])\n @@index([userId])\n @@index([email])\n}\n\nmodel ApiKey {\n id String @id @default(cuid())\n keyPrefix String\n keyHash String @unique\n createdAt DateTime @default(now())\n expiresAt DateTime?\n revokedAt DateTime?\n}\n\n// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).\n// `solfeggioFrequencyId` is the canonical id the kiosk uses (see\n// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.\nmodel ColorPalette {\n solfeggioFrequencyId Int @id\n colors Json // [\"#RRGGBB\", ... x7]\n\n @@map(\"ColorPalette\")\n}\n\n// Append-only audit trail of database mutations. Written centrally by the\n// Prisma extension in src/lib/db.ts; read by the admin Audit Log page.\nmodel AuditLog {\n id String @id @default(uuid())\n createdAt DateTime @default(now())\n userId String?\n email String?\n roleId Int?\n roleName String?\n page String\n action String\n entity String?\n entityId String?\n before Json?\n after Json?\n ip String?\n\n @@index([createdAt])\n @@index([roleId])\n @@index([action])\n @@index([page])\n @@map(\"AuditLog\")\n}\n\n// A listening schedule assigned to one License Key (at most one per key).\n// `type` is \"daily\" | \"weekly\" | null (null = no schedule). Times are\n// wall-clock minutes from midnight (0-1440), interpreted by the kiosk in its\n// own configured timezone. The portal never stores a timezone.\n//\n// `userId` is a legacy owner retained while per-user schedules are migrated to\n// per-license-key; new schedules set `licenseKeyId` and leave `userId` null.\nmodel Schedule {\n id String @id @default(uuid())\n licenseKeyId String? @unique\n licenseKey LicenseKey? @relation(fields: [licenseKeyId], references: [id], onDelete: Cascade)\n userId String?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n type String?\n blocks Block[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n}\n\n// A single time range within a Schedule, plus the ordered set of selected\n// Proclamations to play during it. `day` is null for daily schedules and\n// \"mon\"..\"sun\" for weekly schedules. Overnight (midnight-crossing) ranges are\n// valid: an end clock time before the start belongs to the start day and spills\n// into the next day.\nmodel Block {\n id String @id @default(uuid())\n scheduleId String\n schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)\n day String?\n startMinutes Int\n endMinutes Int\n // When true, the kiosk enables dynamic audio for the duration of this block,\n // overriding any audio selections made on the device. Portal default: true.\n dynamicAudio Boolean @default(true)\n proclamations BlockProclamations[]\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([scheduleId])\n}\n\n// Join table: the Proclamations selected for a Block. Carries the Proclamation\n// content for playback via the API; the portal UI only ever reads\n// title/category/group.\nmodel BlockProclamations {\n blockId String\n block Block @relation(fields: [blockId], references: [id], onDelete: Cascade)\n proclamationId String\n proclamation Proclamation @relation(fields: [proclamationId], references: [id])\n sortOrder Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@id([blockId, proclamationId])\n @@index([proclamationId])\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, @@ -32,10 +32,10 @@ const config: runtime.GetPrismaClientConfig = { } } -config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"roleId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"role\",\"kind\":\"object\",\"type\":\"UserRole\",\"relationName\":\"UserToUserRole\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"accounts\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToUser\"},{\"name\":\"profiles\",\"kind\":\"object\",\"type\":\"Profile\",\"relationName\":\"ProfileToUser\"},{\"name\":\"licenseKeys\",\"kind\":\"object\",\"type\":\"LicenseKey\",\"relationName\":\"LicenseKeyToUser\"},{\"name\":\"userProclamations\",\"kind\":\"object\",\"type\":\"UserProclamations\",\"relationName\":\"UserToUserProclamations\"},{\"name\":\"schedule\",\"kind\":\"object\",\"type\":\"Schedule\",\"relationName\":\"ScheduleToUser\"}],\"dbName\":\"user\"},\"Profile\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ProfileToUser\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"phoneNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ghlContactId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"ghl_contact_id\"},{\"name\":\"addresses\",\"kind\":\"object\",\"type\":\"Address\",\"relationName\":\"AddressToProfile\"}],\"dbName\":null},\"Address\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"profileId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"profile\",\"kind\":\"object\",\"type\":\"Profile\",\"relationName\":\"AddressToProfile\"},{\"name\":\"street\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"city\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"state\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"postalCode\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"country\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDefaultMailing\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"isDefaultBilling\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"isDefaultShipping\",\"kind\":\"scalar\",\"type\":\"Boolean\"}],\"dbName\":null},\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":\"session\"},\"Account\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"providerId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"AccountToUser\"},{\"name\":\"accessToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"idToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"scope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"account\"},\"UserRole\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserRole\"}],\"dbName\":null},\"Verification\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"verification\"},\"Proclamation\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"solfeggioFrequencyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"solfeggioFrequency\",\"kind\":\"object\",\"type\":\"SolfeggioFrequency\",\"relationName\":\"ProclamationToSolfeggioFrequency\"},{\"name\":\"categoryId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"category\",\"kind\":\"object\",\"type\":\"ProclamationCategory\",\"relationName\":\"ProclamationToProclamationCategory\"},{\"name\":\"groupId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"group\",\"kind\":\"object\",\"type\":\"ProclamationGroup\",\"relationName\":\"ProclamationToProclamationGroup\"},{\"name\":\"userProclamations\",\"kind\":\"object\",\"type\":\"UserProclamations\",\"relationName\":\"ProclamationToUserProclamations\"},{\"name\":\"blockProclamations\",\"kind\":\"object\",\"type\":\"BlockProclamations\",\"relationName\":\"BlockProclamationsToProclamation\"}],\"dbName\":null},\"ProclamationGroup\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sortOrder\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToProclamationGroup\"}],\"dbName\":null},\"UserProclamations\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserProclamations\"},{\"name\":\"proclamationId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"proclamation\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToUserProclamations\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SolfeggioFrequency\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToSolfeggioFrequency\"}],\"dbName\":null},\"ProclamationCategory\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sortOrder\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToProclamationCategory\"}],\"dbName\":null},\"LicenseKey\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"lastValidatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"LicenseKeyToUser\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"ApiKey\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"keyPrefix\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"keyHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"revokedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"ColorPalette\":{\"fields\":[{\"name\":\"solfeggioFrequencyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"}],\"dbName\":\"ColorPalette\"},\"AuditLog\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"roleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"page\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"action\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"entity\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"entityId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"before\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"after\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"ip\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":\"AuditLog\"},\"Schedule\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ScheduleToUser\"},{\"name\":\"blocks\",\"kind\":\"object\",\"type\":\"Block\",\"relationName\":\"BlockToSchedule\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Block\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"scheduleId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"schedule\",\"kind\":\"object\",\"type\":\"Schedule\",\"relationName\":\"BlockToSchedule\"},{\"name\":\"startMinutes\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"endMinutes\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"dynamicAudio\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"BlockProclamations\",\"relationName\":\"BlockToBlockProclamations\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"BlockProclamations\":{\"fields\":[{\"name\":\"blockId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"block\",\"kind\":\"object\",\"type\":\"Block\",\"relationName\":\"BlockToBlockProclamations\"},{\"name\":\"proclamationId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"proclamation\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"BlockProclamationsToProclamation\"},{\"name\":\"sortOrder\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"roleId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"role\",\"kind\":\"object\",\"type\":\"UserRole\",\"relationName\":\"UserToUserRole\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"accounts\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToUser\"},{\"name\":\"profiles\",\"kind\":\"object\",\"type\":\"Profile\",\"relationName\":\"ProfileToUser\"},{\"name\":\"licenseKeys\",\"kind\":\"object\",\"type\":\"LicenseKey\",\"relationName\":\"LicenseKeyToUser\"},{\"name\":\"userProclamations\",\"kind\":\"object\",\"type\":\"UserProclamations\",\"relationName\":\"UserToUserProclamations\"},{\"name\":\"schedules\",\"kind\":\"object\",\"type\":\"Schedule\",\"relationName\":\"ScheduleToUser\"}],\"dbName\":\"user\"},\"Profile\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ProfileToUser\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"phoneNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ghlContactId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"ghl_contact_id\"},{\"name\":\"addresses\",\"kind\":\"object\",\"type\":\"Address\",\"relationName\":\"AddressToProfile\"}],\"dbName\":null},\"Address\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"profileId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"profile\",\"kind\":\"object\",\"type\":\"Profile\",\"relationName\":\"AddressToProfile\"},{\"name\":\"street\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"city\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"state\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"postalCode\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"country\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDefaultMailing\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"isDefaultBilling\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"isDefaultShipping\",\"kind\":\"scalar\",\"type\":\"Boolean\"}],\"dbName\":null},\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":\"session\"},\"Account\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"providerId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"AccountToUser\"},{\"name\":\"accessToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"idToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"scope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"account\"},\"UserRole\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserRole\"}],\"dbName\":null},\"Verification\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"verification\"},\"Proclamation\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"solfeggioFrequencyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"solfeggioFrequency\",\"kind\":\"object\",\"type\":\"SolfeggioFrequency\",\"relationName\":\"ProclamationToSolfeggioFrequency\"},{\"name\":\"categoryId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"category\",\"kind\":\"object\",\"type\":\"ProclamationCategory\",\"relationName\":\"ProclamationToProclamationCategory\"},{\"name\":\"groupId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"group\",\"kind\":\"object\",\"type\":\"ProclamationGroup\",\"relationName\":\"ProclamationToProclamationGroup\"},{\"name\":\"userProclamations\",\"kind\":\"object\",\"type\":\"UserProclamations\",\"relationName\":\"ProclamationToUserProclamations\"},{\"name\":\"blockProclamations\",\"kind\":\"object\",\"type\":\"BlockProclamations\",\"relationName\":\"BlockProclamationsToProclamation\"}],\"dbName\":null},\"ProclamationGroup\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sortOrder\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToProclamationGroup\"}],\"dbName\":null},\"UserProclamations\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToUserProclamations\"},{\"name\":\"proclamationId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"proclamation\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToUserProclamations\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SolfeggioFrequency\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToSolfeggioFrequency\"}],\"dbName\":null},\"ProclamationCategory\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sortOrder\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"ProclamationToProclamationCategory\"}],\"dbName\":null},\"LicenseKey\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"lastValidatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"LicenseKeyToUser\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"schedule\",\"kind\":\"object\",\"type\":\"Schedule\",\"relationName\":\"LicenseKeyToSchedule\"},{\"name\":\"systemName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"systemNameLower\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"ApiKey\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"keyPrefix\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"keyHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"revokedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"ColorPalette\":{\"fields\":[{\"name\":\"solfeggioFrequencyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"}],\"dbName\":\"ColorPalette\"},\"AuditLog\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"roleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"page\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"action\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"entity\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"entityId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"before\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"after\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"ip\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":\"AuditLog\"},\"Schedule\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"licenseKeyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"licenseKey\",\"kind\":\"object\",\"type\":\"LicenseKey\",\"relationName\":\"LicenseKeyToSchedule\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ScheduleToUser\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"blocks\",\"kind\":\"object\",\"type\":\"Block\",\"relationName\":\"BlockToSchedule\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Block\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"scheduleId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"schedule\",\"kind\":\"object\",\"type\":\"Schedule\",\"relationName\":\"BlockToSchedule\"},{\"name\":\"day\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startMinutes\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"endMinutes\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"dynamicAudio\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"proclamations\",\"kind\":\"object\",\"type\":\"BlockProclamations\",\"relationName\":\"BlockToBlockProclamations\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"BlockProclamations\":{\"fields\":[{\"name\":\"blockId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"block\",\"kind\":\"object\",\"type\":\"Block\",\"relationName\":\"BlockToBlockProclamations\"},{\"name\":\"proclamationId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"proclamation\",\"kind\":\"object\",\"type\":\"Proclamation\",\"relationName\":\"BlockProclamationsToProclamation\"},{\"name\":\"sortOrder\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") config.parameterizationSchema = { - strings: JSON.parse("[\"where\",\"orderBy\",\"cursor\",\"users\",\"_count\",\"role\",\"user\",\"sessions\",\"accounts\",\"profile\",\"addresses\",\"profiles\",\"licenseKeys\",\"proclamations\",\"solfeggioFrequency\",\"category\",\"group\",\"userProclamations\",\"blocks\",\"schedule\",\"block\",\"proclamation\",\"blockProclamations\",\"User.findUnique\",\"User.findUniqueOrThrow\",\"User.findFirst\",\"User.findFirstOrThrow\",\"User.findMany\",\"data\",\"User.createOne\",\"User.createMany\",\"User.createManyAndReturn\",\"User.updateOne\",\"User.updateMany\",\"User.updateManyAndReturn\",\"create\",\"update\",\"User.upsertOne\",\"User.deleteOne\",\"User.deleteMany\",\"having\",\"_avg\",\"_sum\",\"_min\",\"_max\",\"User.groupBy\",\"User.aggregate\",\"Profile.findUnique\",\"Profile.findUniqueOrThrow\",\"Profile.findFirst\",\"Profile.findFirstOrThrow\",\"Profile.findMany\",\"Profile.createOne\",\"Profile.createMany\",\"Profile.createManyAndReturn\",\"Profile.updateOne\",\"Profile.updateMany\",\"Profile.updateManyAndReturn\",\"Profile.upsertOne\",\"Profile.deleteOne\",\"Profile.deleteMany\",\"Profile.groupBy\",\"Profile.aggregate\",\"Address.findUnique\",\"Address.findUniqueOrThrow\",\"Address.findFirst\",\"Address.findFirstOrThrow\",\"Address.findMany\",\"Address.createOne\",\"Address.createMany\",\"Address.createManyAndReturn\",\"Address.updateOne\",\"Address.updateMany\",\"Address.updateManyAndReturn\",\"Address.upsertOne\",\"Address.deleteOne\",\"Address.deleteMany\",\"Address.groupBy\",\"Address.aggregate\",\"Session.findUnique\",\"Session.findUniqueOrThrow\",\"Session.findFirst\",\"Session.findFirstOrThrow\",\"Session.findMany\",\"Session.createOne\",\"Session.createMany\",\"Session.createManyAndReturn\",\"Session.updateOne\",\"Session.updateMany\",\"Session.updateManyAndReturn\",\"Session.upsertOne\",\"Session.deleteOne\",\"Session.deleteMany\",\"Session.groupBy\",\"Session.aggregate\",\"Account.findUnique\",\"Account.findUniqueOrThrow\",\"Account.findFirst\",\"Account.findFirstOrThrow\",\"Account.findMany\",\"Account.createOne\",\"Account.createMany\",\"Account.createManyAndReturn\",\"Account.updateOne\",\"Account.updateMany\",\"Account.updateManyAndReturn\",\"Account.upsertOne\",\"Account.deleteOne\",\"Account.deleteMany\",\"Account.groupBy\",\"Account.aggregate\",\"UserRole.findUnique\",\"UserRole.findUniqueOrThrow\",\"UserRole.findFirst\",\"UserRole.findFirstOrThrow\",\"UserRole.findMany\",\"UserRole.createOne\",\"UserRole.createMany\",\"UserRole.createManyAndReturn\",\"UserRole.updateOne\",\"UserRole.updateMany\",\"UserRole.updateManyAndReturn\",\"UserRole.upsertOne\",\"UserRole.deleteOne\",\"UserRole.deleteMany\",\"UserRole.groupBy\",\"UserRole.aggregate\",\"Verification.findUnique\",\"Verification.findUniqueOrThrow\",\"Verification.findFirst\",\"Verification.findFirstOrThrow\",\"Verification.findMany\",\"Verification.createOne\",\"Verification.createMany\",\"Verification.createManyAndReturn\",\"Verification.updateOne\",\"Verification.updateMany\",\"Verification.updateManyAndReturn\",\"Verification.upsertOne\",\"Verification.deleteOne\",\"Verification.deleteMany\",\"Verification.groupBy\",\"Verification.aggregate\",\"Proclamation.findUnique\",\"Proclamation.findUniqueOrThrow\",\"Proclamation.findFirst\",\"Proclamation.findFirstOrThrow\",\"Proclamation.findMany\",\"Proclamation.createOne\",\"Proclamation.createMany\",\"Proclamation.createManyAndReturn\",\"Proclamation.updateOne\",\"Proclamation.updateMany\",\"Proclamation.updateManyAndReturn\",\"Proclamation.upsertOne\",\"Proclamation.deleteOne\",\"Proclamation.deleteMany\",\"Proclamation.groupBy\",\"Proclamation.aggregate\",\"ProclamationGroup.findUnique\",\"ProclamationGroup.findUniqueOrThrow\",\"ProclamationGroup.findFirst\",\"ProclamationGroup.findFirstOrThrow\",\"ProclamationGroup.findMany\",\"ProclamationGroup.createOne\",\"ProclamationGroup.createMany\",\"ProclamationGroup.createManyAndReturn\",\"ProclamationGroup.updateOne\",\"ProclamationGroup.updateMany\",\"ProclamationGroup.updateManyAndReturn\",\"ProclamationGroup.upsertOne\",\"ProclamationGroup.deleteOne\",\"ProclamationGroup.deleteMany\",\"ProclamationGroup.groupBy\",\"ProclamationGroup.aggregate\",\"UserProclamations.findUnique\",\"UserProclamations.findUniqueOrThrow\",\"UserProclamations.findFirst\",\"UserProclamations.findFirstOrThrow\",\"UserProclamations.findMany\",\"UserProclamations.createOne\",\"UserProclamations.createMany\",\"UserProclamations.createManyAndReturn\",\"UserProclamations.updateOne\",\"UserProclamations.updateMany\",\"UserProclamations.updateManyAndReturn\",\"UserProclamations.upsertOne\",\"UserProclamations.deleteOne\",\"UserProclamations.deleteMany\",\"UserProclamations.groupBy\",\"UserProclamations.aggregate\",\"SolfeggioFrequency.findUnique\",\"SolfeggioFrequency.findUniqueOrThrow\",\"SolfeggioFrequency.findFirst\",\"SolfeggioFrequency.findFirstOrThrow\",\"SolfeggioFrequency.findMany\",\"SolfeggioFrequency.createOne\",\"SolfeggioFrequency.createMany\",\"SolfeggioFrequency.createManyAndReturn\",\"SolfeggioFrequency.updateOne\",\"SolfeggioFrequency.updateMany\",\"SolfeggioFrequency.updateManyAndReturn\",\"SolfeggioFrequency.upsertOne\",\"SolfeggioFrequency.deleteOne\",\"SolfeggioFrequency.deleteMany\",\"SolfeggioFrequency.groupBy\",\"SolfeggioFrequency.aggregate\",\"ProclamationCategory.findUnique\",\"ProclamationCategory.findUniqueOrThrow\",\"ProclamationCategory.findFirst\",\"ProclamationCategory.findFirstOrThrow\",\"ProclamationCategory.findMany\",\"ProclamationCategory.createOne\",\"ProclamationCategory.createMany\",\"ProclamationCategory.createManyAndReturn\",\"ProclamationCategory.updateOne\",\"ProclamationCategory.updateMany\",\"ProclamationCategory.updateManyAndReturn\",\"ProclamationCategory.upsertOne\",\"ProclamationCategory.deleteOne\",\"ProclamationCategory.deleteMany\",\"ProclamationCategory.groupBy\",\"ProclamationCategory.aggregate\",\"LicenseKey.findUnique\",\"LicenseKey.findUniqueOrThrow\",\"LicenseKey.findFirst\",\"LicenseKey.findFirstOrThrow\",\"LicenseKey.findMany\",\"LicenseKey.createOne\",\"LicenseKey.createMany\",\"LicenseKey.createManyAndReturn\",\"LicenseKey.updateOne\",\"LicenseKey.updateMany\",\"LicenseKey.updateManyAndReturn\",\"LicenseKey.upsertOne\",\"LicenseKey.deleteOne\",\"LicenseKey.deleteMany\",\"LicenseKey.groupBy\",\"LicenseKey.aggregate\",\"ApiKey.findUnique\",\"ApiKey.findUniqueOrThrow\",\"ApiKey.findFirst\",\"ApiKey.findFirstOrThrow\",\"ApiKey.findMany\",\"ApiKey.createOne\",\"ApiKey.createMany\",\"ApiKey.createManyAndReturn\",\"ApiKey.updateOne\",\"ApiKey.updateMany\",\"ApiKey.updateManyAndReturn\",\"ApiKey.upsertOne\",\"ApiKey.deleteOne\",\"ApiKey.deleteMany\",\"ApiKey.groupBy\",\"ApiKey.aggregate\",\"ColorPalette.findUnique\",\"ColorPalette.findUniqueOrThrow\",\"ColorPalette.findFirst\",\"ColorPalette.findFirstOrThrow\",\"ColorPalette.findMany\",\"ColorPalette.createOne\",\"ColorPalette.createMany\",\"ColorPalette.createManyAndReturn\",\"ColorPalette.updateOne\",\"ColorPalette.updateMany\",\"ColorPalette.updateManyAndReturn\",\"ColorPalette.upsertOne\",\"ColorPalette.deleteOne\",\"ColorPalette.deleteMany\",\"ColorPalette.groupBy\",\"ColorPalette.aggregate\",\"AuditLog.findUnique\",\"AuditLog.findUniqueOrThrow\",\"AuditLog.findFirst\",\"AuditLog.findFirstOrThrow\",\"AuditLog.findMany\",\"AuditLog.createOne\",\"AuditLog.createMany\",\"AuditLog.createManyAndReturn\",\"AuditLog.updateOne\",\"AuditLog.updateMany\",\"AuditLog.updateManyAndReturn\",\"AuditLog.upsertOne\",\"AuditLog.deleteOne\",\"AuditLog.deleteMany\",\"AuditLog.groupBy\",\"AuditLog.aggregate\",\"Schedule.findUnique\",\"Schedule.findUniqueOrThrow\",\"Schedule.findFirst\",\"Schedule.findFirstOrThrow\",\"Schedule.findMany\",\"Schedule.createOne\",\"Schedule.createMany\",\"Schedule.createManyAndReturn\",\"Schedule.updateOne\",\"Schedule.updateMany\",\"Schedule.updateManyAndReturn\",\"Schedule.upsertOne\",\"Schedule.deleteOne\",\"Schedule.deleteMany\",\"Schedule.groupBy\",\"Schedule.aggregate\",\"Block.findUnique\",\"Block.findUniqueOrThrow\",\"Block.findFirst\",\"Block.findFirstOrThrow\",\"Block.findMany\",\"Block.createOne\",\"Block.createMany\",\"Block.createManyAndReturn\",\"Block.updateOne\",\"Block.updateMany\",\"Block.updateManyAndReturn\",\"Block.upsertOne\",\"Block.deleteOne\",\"Block.deleteMany\",\"Block.groupBy\",\"Block.aggregate\",\"BlockProclamations.findUnique\",\"BlockProclamations.findUniqueOrThrow\",\"BlockProclamations.findFirst\",\"BlockProclamations.findFirstOrThrow\",\"BlockProclamations.findMany\",\"BlockProclamations.createOne\",\"BlockProclamations.createMany\",\"BlockProclamations.createManyAndReturn\",\"BlockProclamations.updateOne\",\"BlockProclamations.updateMany\",\"BlockProclamations.updateManyAndReturn\",\"BlockProclamations.upsertOne\",\"BlockProclamations.deleteOne\",\"BlockProclamations.deleteMany\",\"BlockProclamations.groupBy\",\"BlockProclamations.aggregate\",\"AND\",\"OR\",\"NOT\",\"blockId\",\"proclamationId\",\"sortOrder\",\"createdAt\",\"updatedAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"contains\",\"startsWith\",\"endsWith\",\"id\",\"scheduleId\",\"startMinutes\",\"endMinutes\",\"dynamicAudio\",\"userId\",\"every\",\"some\",\"none\",\"email\",\"roleId\",\"roleName\",\"page\",\"action\",\"entity\",\"entityId\",\"before\",\"after\",\"ip\",\"string_contains\",\"string_starts_with\",\"string_ends_with\",\"array_starts_with\",\"array_ends_with\",\"array_contains\",\"solfeggioFrequencyId\",\"colors\",\"keyPrefix\",\"keyHash\",\"expiresAt\",\"revokedAt\",\"key\",\"active\",\"lastValidatedAt\",\"name\",\"value\",\"content\",\"categoryId\",\"groupId\",\"identifier\",\"accountId\",\"providerId\",\"accessToken\",\"refreshToken\",\"idToken\",\"accessTokenExpiresAt\",\"refreshTokenExpiresAt\",\"scope\",\"password\",\"token\",\"ipAddress\",\"userAgent\",\"profileId\",\"street\",\"city\",\"state\",\"postalCode\",\"country\",\"type\",\"isDefaultMailing\",\"isDefaultBilling\",\"isDefaultShipping\",\"firstName\",\"lastName\",\"phoneNumber\",\"ghlContactId\",\"emailVerified\",\"image\",\"blockId_proclamationId\",\"userId_proclamationId\",\"is\",\"isNot\",\"connectOrCreate\",\"upsert\",\"createMany\",\"set\",\"disconnect\",\"delete\",\"connect\",\"updateMany\",\"deleteMany\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"), - graph: "gAi2AbACEgUAAN0EACAHAADeBAAgCAAA3wQAIAsAAOAEACAMAADhBAAgEQAA0QQAIBMAAOIEACDPAgAA3AQAMNACAAAFABDRAgAA3AQAMNUCQACTBAAh1gJAAJMEACHiAgEAAAAB6wIBAAAAAewCAgCjBAAhhAMBAKEEACGkAyAAxgQAIaUDAQCiBAAhAQAAAAEAIAYDAAC_BAAgzwIAAL4EADDQAgAAAwAQ0QIAAL4EADDiAgIAqQQAIYQDAQChBAAhAQAAAAMAIBIFAADdBAAgBwAA3gQAIAgAAN8EACALAADgBAAgDAAA4QQAIBEAANEEACATAADiBAAgzwIAANwEADDQAgAABQAQ0QIAANwEADDVAkAAkwQAIdYCQACTBAAh4gIBAKEEACHrAgEAoQQAIewCAgCjBAAhhAMBAKEEACGkAyAAxgQAIaUDAQCiBAAhCQUAAKMHACAHAACkBwAgCAAApQcAIAsAAKYHACAMAACnBwAgEQAAoAcAIBMAAJkHACDsAgAAmgUAIKUDAACaBQAgAwAAAAUAIAEAAAYAMAIAAAEAIAEAAAAFACAMBgAAlAQAIM8CAADbBAAw0AIAAAkAENECAADbBAAw1QJAAJMEACHWAkAAkwQAIeICAQChBAAh5wIBAKEEACH_AkAAkwQAIZMDAQChBAAhlAMBAKIEACGVAwEAogQAIQMGAACYBQAglAMAAJoFACCVAwAAmgUAIAwGAACUBAAgzwIAANsEADDQAgAACQAQ0QIAANsEADDVAkAAkwQAIdYCQACTBAAh4gIBAAAAAecCAQChBAAh_wJAAJMEACGTAwEAAAABlAMBAKIEACGVAwEAogQAIQMAAAAJACABAAAKADACAAALACARBgAAlAQAIM8CAADaBAAw0AIAAA0AENECAADaBAAw1QJAAJMEACHWAkAAkwQAIeICAQChBAAh5wIBAKEEACGKAwEAoQQAIYsDAQChBAAhjAMBAKIEACGNAwEAogQAIY4DAQCiBAAhjwNAALAEACGQA0AAsAQAIZEDAQCiBAAhkgMBAKIEACEIBgAAmAUAIIwDAACaBQAgjQMAAJoFACCOAwAAmgUAII8DAACaBQAgkAMAAJoFACCRAwAAmgUAIJIDAACaBQAgEQYAAJQEACDPAgAA2gQAMNACAAANABDRAgAA2gQAMNUCQACTBAAh1gJAAJMEACHiAgEAAAAB5wIBAKEEACGKAwEAoQQAIYsDAQChBAAhjAMBAKIEACGNAwEAogQAIY4DAQCiBAAhjwNAALAEACGQA0AAsAQAIZEDAQCiBAAhkgMBAKIEACEDAAAADQAgAQAADgAwAgAADwAgDAYAANUEACAKAADZBAAgzwIAANgEADDQAgAAEQAQ0QIAANgEADDiAgEAoQQAIecCAQCiBAAh6wIBAKIEACGgAwEAoQQAIaEDAQChBAAhogMBAKEEACGjAwEAogQAIQUGAACYBQAgCgAAogcAIOcCAACaBQAg6wIAAJoFACCjAwAAmgUAIAwGAADVBAAgCgAA2QQAIM8CAADYBAAw0AIAABEAENECAADYBAAw4gIBAAAAAecCAQCiBAAh6wIBAKIEACGgAwEAoQQAIaEDAQChBAAhogMBAKEEACGjAwEAogQAIQMAAAARACABAAASADACAAATACABAAAABQAgDwkAANcEACDPAgAA1gQAMNACAAAWABDRAgAA1gQAMOICAQChBAAhlgMBAKEEACGXAwEAoQQAIZgDAQChBAAhmQMBAKEEACGaAwEAoQQAIZsDAQChBAAhnAMBAKEEACGdAyAAxgQAIZ4DIADGBAAhnwMgAMYEACEBCQAAoQcAIA8JAADXBAAgzwIAANYEADDQAgAAFgAQ0QIAANYEADDiAgEAAAABlgMBAKEEACGXAwEAoQQAIZgDAQChBAAhmQMBAKEEACGaAwEAoQQAIZsDAQChBAAhnAMBAKEEACGdAyAAxgQAIZ4DIADGBAAhnwMgAMYEACEDAAAAFgAgAQAAFwAwAgAAGAAgAQAAABYAIAsGAADVBAAgzwIAANQEADDQAgAAGwAQ0QIAANQEADDVAkAAkwQAIeICAQChBAAh5wIBAKIEACHrAgEAogQAIYEDAQChBAAhggMgAMYEACGDA0AAsAQAIQQGAACYBQAg5wIAAJoFACDrAgAAmgUAIIMDAACaBQAgCwYAANUEACDPAgAA1AQAMNACAAAbABDRAgAA1AQAMNUCQACTBAAh4gIBAAAAAecCAQCiBAAh6wIBAKIEACGBAwEAAAABggMgAMYEACGDA0AAsAQAIQMAAAAbACABAAAcADACAAAdACABAAAABQAgCQYAAJQEACAVAADMBAAgzwIAANMEADDQAgAAIAAQ0QIAANMEADDTAgEAoQQAIdUCQACTBAAh1gJAAJMEACHnAgEAoQQAIQIGAACYBQAgFQAAnAcAIAoGAACUBAAgFQAAzAQAIM8CAADTBAAw0AIAACAAENECAADTBAAw0wIBAKEEACHVAkAAkwQAIdYCQACTBAAh5wIBAKEEACGnAwAA0gQAIAMAAAAgACABAAAhADACAAAiACAODgAAzgQAIA8AAM8EACAQAADQBAAgEQAA0QQAIBYAAMgEACDPAgAAzQQAMNACAAAkABDRAgAAzQQAMOICAQChBAAh-wICAKkEACGEAwEAoQQAIYYDAQChBAAhhwMCAKkEACGIAwIAqQQAIQUOAACdBwAgDwAAngcAIBAAAJ8HACARAACgBwAgFgAAmgcAIA4OAADOBAAgDwAAzwQAIBAAANAEACARAADRBAAgFgAAyAQAIM8CAADNBAAw0AIAACQAENECAADNBAAw4gIBAAAAAfsCAgCpBAAhhAMBAAAAAYYDAQChBAAhhwMCAKkEACGIAwIAqQQAIQMAAAAkACABAAAlADACAAAmACABAAAAJAAgAwAAACQAIAEAACUAMAIAACYAIAEAAAAkACADAAAAJAAgAQAAJQAwAgAAJgAgAQAAACQAIAMAAAAgACABAAAhADACAAAiACAKFAAAywQAIBUAAMwEACDPAgAAygQAMNACAAAuABDRAgAAygQAMNICAQChBAAh0wIBAKEEACHUAgIAqQQAIdUCQACTBAAh1gJAAJMEACECFAAAmwcAIBUAAJwHACALFAAAywQAIBUAAMwEACDPAgAAygQAMNACAAAuABDRAgAAygQAMNICAQChBAAh0wIBAKEEACHUAgIAqQQAIdUCQACTBAAh1gJAAJMEACGmAwAAyQQAIAMAAAAuACABAAAvADACAAAwACAMDQAAyAQAIBMAAMcEACDPAgAAxQQAMNACAAAyABDRAgAAxQQAMNUCQACTBAAh1gJAAJMEACHiAgEAoQQAIeMCAQChBAAh5AICAKkEACHlAgIAqQQAIeYCIADGBAAhAg0AAJoHACATAACZBwAgDA0AAMgEACATAADHBAAgzwIAAMUEADDQAgAAMgAQ0QIAAMUEADDVAkAAkwQAIdYCQACTBAAh4gIBAAAAAeMCAQChBAAh5AICAKkEACHlAgIAqQQAIeYCIADGBAAhAwAAADIAIAEAADMAMAIAADQAIAEAAAAyACADAAAALgAgAQAALwAwAgAAMAAgAQAAAC4AIAEAAAAgACABAAAALgAgCQYAAJQEACASAACVBAAgzwIAAJIEADDQAgAAOwAQ0QIAAJIEADDVAkAAkwQAIdYCQACTBAAh4gIBAKEEACHnAgEAoQQAIQEAAAA7ACABAAAACQAgAQAAAA0AIAEAAAARACABAAAAGwAgAQAAACAAIAEAAAABACADAAAABQAgAQAABgAwAgAAAQAgAwAAAAUAIAEAAAYAMAIAAAEAIAMAAAAFACABAAAGADACAAABACAPBQAAmAcAIAcAAPYGACAIAAD3BgAgCwAA-AYAIAwAAPkGACARAAD6BgAgEwAA-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_wJAAAAAAZMDAQAAAAGUAwEAAAABlQMBAAAAAQEcAACMAQAgCNUCQAAAAAHWAkAAAAAB4gIBAAAAAecCAQAAAAH_AkAAAAABkwMBAAAAAZQDAQAAAAGVAwEAAAABARwAAI4BADABHAAAjgEAMAkGAACGBwAg1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh5wIBAOoEACH_AkAA6QQAIZMDAQDqBAAhlAMBAKAFACGVAwEAoAUAIQIAAAALACAcAACRAQAgCNUCQADpBAAh1gJAAOkEACHiAgEA6gQAIecCAQDqBAAh_wJAAOkEACGTAwEA6gQAIZQDAQCgBQAhlQMBAKAFACECAAAACQAgHAAAkwEAIAIAAAAJACAcAACTAQAgAwAAAAsAICMAAIwBACAkAACRAQAgAQAAAAsAIAEAAAAJACAFBAAAgwcAICsAAIUHACAsAACEBwAglAMAAJoFACCVAwAAmgUAIAvPAgAAwQQAMNACAACaAQAQ0QIAAMEEADDVAkAAhQQAIdYCQACFBAAh4gIBAIMEACHnAgEAgwQAIf8CQACFBAAhkwMBAIMEACGUAwEAlwQAIZUDAQCXBAAhAwAAAAkAIAEAAJkBADAoAACaAQAgAwAAAAkAIAEAAAoAMAIAAAsAIAEAAAAPACABAAAADwAgAwAAAA0AIAEAAA4AMAIAAA8AIAMAAAANACABAAAOADACAAAPACADAAAADQAgAQAADgAwAgAADwAgDgYAAIIHACDVAkAAAAAB1gJAAAAAAeICAQAAAAHnAgEAAAABigMBAAAAAYsDAQAAAAGMAwEAAAABjQMBAAAAAY4DAQAAAAGPA0AAAAABkANAAAAAAZEDAQAAAAGSAwEAAAABARwAAKIBACAN1QJAAAAAAdYCQAAAAAHiAgEAAAAB5wIBAAAAAYoDAQAAAAGLAwEAAAABjAMBAAAAAY0DAQAAAAGOAwEAAAABjwNAAAAAAZADQAAAAAGRAwEAAAABkgMBAAAAAQEcAACkAQAwARwAAKQBADAOBgAAgQcAINUCQADpBAAh1gJAAOkEACHiAgEA6gQAIecCAQDqBAAhigMBAOoEACGLAwEA6gQAIYwDAQCgBQAhjQMBAKAFACGOAwEAoAUAIY8DQACqBQAhkANAAKoFACGRAwEAoAUAIZIDAQCgBQAhAgAAAA8AIBwAAKcBACAN1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh5wIBAOoEACGKAwEA6gQAIYsDAQDqBAAhjAMBAKAFACGNAwEAoAUAIY4DAQCgBQAhjwNAAKoFACGQA0AAqgUAIZEDAQCgBQAhkgMBAKAFACECAAAADQAgHAAAqQEAIAIAAAANACAcAACpAQAgAwAAAA8AICMAAKIBACAkAACnAQAgAQAAAA8AIAEAAAANACAKBAAA_gYAICsAAIAHACAsAAD_BgAgjAMAAJoFACCNAwAAmgUAII4DAACaBQAgjwMAAJoFACCQAwAAmgUAIJEDAACaBQAgkgMAAJoFACAQzwIAAMAEADDQAgAAsAEAENECAADABAAw1QJAAIUEACHWAkAAhQQAIeICAQCDBAAh5wIBAIMEACGKAwEAgwQAIYsDAQCDBAAhjAMBAJcEACGNAwEAlwQAIY4DAQCXBAAhjwNAAKwEACGQA0AArAQAIZEDAQCXBAAhkgMBAJcEACEDAAAADQAgAQAArwEAMCgAALABACADAAAADQAgAQAADgAwAgAADwAgBgMAAL8EACDPAgAAvgQAMNACAAADABDRAgAAvgQAMOICAgAAAAGEAwEAoQQAIQEAAACzAQAgAQAAALMBACABAwAA_QYAIAMAAAADACABAAC2AQAwAgAAswEAIAMAAAADACABAAC2AQAwAgAAswEAIAMAAAADACABAAC2AQAwAgAAswEAIAMDAAD8BgAg4gICAAAAAYQDAQAAAAEBHAAAugEAIALiAgIAAAABhAMBAAAAAQEcAAC8AQAwARwAALwBADADAwAAlwYAIOICAgDoBAAhhAMBAOoEACECAAAAswEAIBwAAL8BACAC4gICAOgEACGEAwEA6gQAIQIAAAADACAcAADBAQAgAgAAAAMAIBwAAMEBACADAAAAswEAICMAALoBACAkAAC_AQAgAQAAALMBACABAAAAAwAgBQQAAJIGACApAACTBgAgKgAAlgYAICsAAJUGACAsAACUBgAgBc8CAAC9BAAw0AIAAMgBABDRAgAAvQQAMOICAgCEBAAhhAMBAIMEACEDAAAAAwAgAQAAxwEAMCgAAMgBACADAAAAAwAgAQAAtgEAMAIAALMBACAJzwIAALwEADDQAgAAzgEAENECAAC8BAAw1QJAAJMEACHWAkAAkwQAIeICAQAAAAH_AkAAkwQAIYUDAQChBAAhiQMBAKEEACEBAAAAywEAIAEAAADLAQAgCc8CAAC8BAAw0AIAAM4BABDRAgAAvAQAMNUCQACTBAAh1gJAAJMEACHiAgEAoQQAIf8CQACTBAAhhQMBAKEEACGJAwEAoQQAIQADAAAAzgEAIAEAAM8BADACAADLAQAgAwAAAM4BACABAADPAQAwAgAAywEAIAMAAADOAQAgAQAAzwEAMAIAAMsBACAG1QJAAAAAAdYCQAAAAAHiAgEAAAAB_wJAAAAAAYUDAQAAAAGJAwEAAAABARwAANMBACAG1QJAAAAAAdYCQAAAAAHiAgEAAAAB_wJAAAAAAYUDAQAAAAGJAwEAAAABARwAANUBADABHAAA1QEAMAbVAkAA6QQAIdYCQADpBAAh4gIBAOoEACH_AkAA6QQAIYUDAQDqBAAhiQMBAOoEACECAAAAywEAIBwAANgBACAG1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh_wJAAOkEACGFAwEA6gQAIYkDAQDqBAAhAgAAAM4BACAcAADaAQAgAgAAAM4BACAcAADaAQAgAwAAAMsBACAjAADTAQAgJAAA2AEAIAEAAADLAQAgAQAAAM4BACADBAAAjwYAICsAAJEGACAsAACQBgAgCc8CAAC7BAAw0AIAAOEBABDRAgAAuwQAMNUCQACFBAAh1gJAAIUEACHiAgEAgwQAIf8CQACFBAAhhQMBAIMEACGJAwEAgwQAIQMAAADOAQAgAQAA4AEAMCgAAOEBACADAAAAzgEAIAEAAM8BADACAADLAQAgAQAAACYAIAEAAAAmACADAAAAJAAgAQAAJQAwAgAAJgAgAwAAACQAIAEAACUAMAIAACYAIAMAAAAkACABAAAlADACAAAmACALDgAA3QUAIA8AAPMFACAQAADeBQAgEQAA3wUAIBYAAOAFACDiAgEAAAAB-wICAAAAAYQDAQAAAAGGAwEAAAABhwMCAAAAAYgDAgAAAAEBHAAA6QEAIAbiAgEAAAAB-wICAAAAAYQDAQAAAAGGAwEAAAABhwMCAAAAAYgDAgAAAAEBHAAA6wEAMAEcAADrAQAwCw4AAMEFACAPAADxBQAgEAAAwgUAIBEAAMMFACAWAADEBQAg4gIBAOoEACH7AgIA6AQAIYQDAQDqBAAhhgMBAOoEACGHAwIA6AQAIYgDAgDoBAAhAgAAACYAIBwAAO4BACAG4gIBAOoEACH7AgIA6AQAIYQDAQDqBAAhhgMBAOoEACGHAwIA6AQAIYgDAgDoBAAhAgAAACQAIBwAAPABACACAAAAJAAgHAAA8AEAIAMAAAAmACAjAADpAQAgJAAA7gEAIAEAAAAmACABAAAAJAAgBQQAAIoGACApAACLBgAgKgAAjgYAICsAAI0GACAsAACMBgAgCc8CAAC6BAAw0AIAAPcBABDRAgAAugQAMOICAQCDBAAh-wICAIQEACGEAwEAgwQAIYYDAQCDBAAhhwMCAIQEACGIAwIAhAQAIQMAAAAkACABAAD2AQAwKAAA9wEAIAMAAAAkACABAAAlADACAAAmACAHDQAAtAQAIM8CAAC5BAAw0AIAAP0BABDRAgAAuQQAMNQCAgCpBAAh4gICAAAAAYQDAQAAAAEBAAAA-gEAIAEAAAD6AQAgBw0AALQEACDPAgAAuQQAMNACAAD9AQAQ0QIAALkEADDUAgIAqQQAIeICAgCpBAAhhAMBAKEEACEBDQAA4gUAIAMAAAD9AQAgAQAA_gEAMAIAAPoBACADAAAA_QEAIAEAAP4BADACAAD6AQAgAwAAAP0BACABAAD-AQAwAgAA-gEAIAQNAACJBgAg1AICAAAAAeICAgAAAAGEAwEAAAABARwAAIICACAD1AICAAAAAeICAgAAAAGEAwEAAAABARwAAIQCADABHAAAhAIAMAQNAAD_BQAg1AICAOgEACHiAgIA6AQAIYQDAQDqBAAhAgAAAPoBACAcAACHAgAgA9QCAgDoBAAh4gICAOgEACGEAwEA6gQAIQIAAAD9AQAgHAAAiQIAIAIAAAD9AQAgHAAAiQIAIAMAAAD6AQAgIwAAggIAICQAAIcCACABAAAA-gEAIAEAAAD9AQAgBQQAAPoFACApAAD7BQAgKgAA_gUAICsAAP0FACAsAAD8BQAgBs8CAAC4BAAw0AIAAJACABDRAgAAuAQAMNQCAgCEBAAh4gICAIQEACGEAwEAgwQAIQMAAAD9AQAgAQAAjwIAMCgAAJACACADAAAA_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_QIBAKEEACH-AgEAAAAB_wJAALAEACGAA0AAsAQAIQEAAADzAgAgAQAAAPMCACAJzwIAAK8EADDQAgAA9gIAENECAACvBAAw1QJAAJMEACHiAgEAoQQAIf0CAQChBAAh_gIBAKEEACH_AkAAsAQAIYADQACwBAAhAv8CAACaBQAggAMAAJoFACADAAAA9gIAIAEAAPcCADACAADzAgAgAwAAAPYCACABAAD3AgAwAgAA8wIAIAMAAAD2AgAgAQAA9wIAMAIAAPMCACAG1QJAAAAAAeICAQAAAAH9AgEAAAAB_gIBAAAAAf8CQAAAAAGAA0AAAAABARwAAPsCACAG1QJAAAAAAeICAQAAAAH9AgEAAAAB_gIBAAAAAf8CQAAAAAGAA0AAAAABARwAAP0CADABHAAA_QIAMAbVAkAA6QQAIeICAQDqBAAh_QIBAOoEACH-AgEA6gQAIf8CQACqBQAhgANAAKoFACECAAAA8wIAIBwAAIADACAG1QJAAOkEACHiAgEA6gQAIf0CAQDqBAAh_gIBAOoEACH_AkAAqgUAIYADQACqBQAhAgAAAPYCACAcAACCAwAgAgAAAPYCACAcAACCAwAgAwAAAPMCACAjAAD7AgAgJAAAgAMAIAEAAADzAgAgAQAAAPYCACAFBAAApwUAICsAAKkFACAsAACoBQAg_wIAAJoFACCAAwAAmgUAIAnPAgAAqwQAMNACAACJAwAQ0QIAAKsEADDVAkAAhQQAIeICAQCDBAAh_QIBAIMEACH-AgEAgwQAIf8CQACsBAAhgANAAKwEACEDAAAA9gIAIAEAAIgDADAoAACJAwAgAwAAAPYCACABAAD3AgAwAgAA8wIAIAXPAgAAqAQAMNACAACPAwAQ0QIAAKgEADD7AgIAAAAB_AIAAKoEACABAAAAjAMAIAEAAACMAwAgBc8CAACoBAAw0AIAAI8DABDRAgAAqAQAMPsCAgCpBAAh_AIAAKoEACAAAwAAAI8DACABAACQAwAwAgAAjAMAIAMAAACPAwAgAQAAkAMAMAIAAIwDACADAAAAjwMAIAEAAJADADACAACMAwAgAvsCAgAAAAH8AoAAAAABARwAAJQDACAC-wICAAAAAfwCgAAAAAEBHAAAlgMAMAEcAACWAwAwAvsCAgDoBAAh_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_AwAQ0QIAAIIEADDSAgEAgwQAIdMCAQCDBAAh1AICAIQEACHVAkAAhQQAIdYCQACFBAAhAwAAAC4AIAEAAP4DADAoAAD_AwAgAwAAAC4AIAEAAC8AMAIAADAAIAjPAgAAggQAMNACAAD_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_AIAAKoEACAI1wICAAAAAdgCAgAAAATZAgIAAAAE2gICAAAAAdsCAgAAAAHcAgIAAAAB3QICAAAAAd4CAgCHBAAhDNcCgAAAAAHaAoAAAAAB2wKAAAAAAdwCgAAAAAHdAoAAAAAB3gKAAAAAAfUCAQAAAAH2AgEAAAAB9wIBAAAAAfgCgAAAAAH5AoAAAAAB-gKAAAAAAQnPAgAAqwQAMNACAACJAwAQ0QIAAKsEADDVAkAAhQQAIeICAQCDBAAh_QIBAIMEACH-AgEAgwQAIf8CQACsBAAhgANAAKwEACELBAAAmgQAICsAAK4EACAsAACuBAAg1wJAAAAAAdgCQAAAAAXZAkAAAAAF2gJAAAAAAdsCQAAAAAHcAkAAAAAB3QJAAAAAAd4CQACtBAAhCwQAAJoEACArAACuBAAgLAAArgQAINcCQAAAAAHYAkAAAAAF2QJAAAAABdoCQAAAAAHbAkAAAAAB3AJAAAAAAd0CQAAAAAHeAkAArQQAIQjXAkAAAAAB2AJAAAAABdkCQAAAAAXaAkAAAAAB2wJAAAAAAdwCQAAAAAHdAkAAAAAB3gJAAK4EACEJzwIAAK8EADDQAgAA9gIAENECAACvBAAw1QJAAJMEACHiAgEAoQQAIf0CAQChBAAh_gIBAKEEACH_AkAAsAQAIYADQACwBAAhCNcCQAAAAAHYAkAAAAAF2QJAAAAABdoCQAAAAAHbAkAAAAAB3AJAAAAAAd0CQAAAAAHeAkAArgQAIQrPAgAAsQQAMNACAADwAgAQ0QIAALEEADDVAkAAhQQAIeICAQCDBAAh5wIBAJcEACHrAgEAlwQAIYEDAQCDBAAhggMgAI4EACGDA0AArAQAIQbPAgAAsgQAMNACAADYAgAQ0QIAALIEADDUAgIAhAQAIeICAgCEBAAhhAMBAIMEACEHDQAAtAQAIM8CAACzBAAw0AIAAMUCABDRAgAAswQAMNQCAgCpBAAh4gICAKkEACGEAwEAoQQAIQPoAgAAJAAg6QIAACQAIOoCAAAkACAGzwIAALUEADDQAgAAvwIAENECAAC1BAAw4gICAIQEACGEAwEAgwQAIYUDAgCEBAAhBw0AALQEACDPAgAAtgQAMNACAACsAgAQ0QIAALYEADDiAgIAqQQAIYQDAQChBAAhhQMCAKkEACEHzwIAALcEADDQAgAApgIAENECAAC3BAAw0wIBAIMEACHVAkAAhQQAIdYCQACFBAAh5wIBAIMEACEGzwIAALgEADDQAgAAkAIAENECAAC4BAAw1AICAIQEACHiAgIAhAQAIYQDAQCDBAAhBw0AALQEACDPAgAAuQQAMNACAAD9AQAQ0QIAALkEADDUAgIAqQQAIeICAgCpBAAhhAMBAKEEACEJzwIAALoEADDQAgAA9wEAENECAAC6BAAw4gIBAIMEACH7AgIAhAQAIYQDAQCDBAAhhgMBAIMEACGHAwIAhAQAIYgDAgCEBAAhCc8CAAC7BAAw0AIAAOEBABDRAgAAuwQAMNUCQACFBAAh1gJAAIUEACHiAgEAgwQAIf8CQACFBAAhhQMBAIMEACGJAwEAgwQAIQnPAgAAvAQAMNACAADOAQAQ0QIAALwEADDVAkAAkwQAIdYCQACTBAAh4gIBAKEEACH_AkAAkwQAIYUDAQChBAAhiQMBAKEEACEFzwIAAL0EADDQAgAAyAEAENECAAC9BAAw4gICAIQEACGEAwEAgwQAIQYDAAC_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_wJAAJMEACGTAwEAoQQAIZQDAQCiBAAhlQMBAKIEACESBQAA3QQAIAcAAN4EACAIAADfBAAgCwAA4AQAIAwAAOEEACARAADRBAAgEwAA4gQAIM8CAADcBAAw0AIAAAUAENECAADcBAAw1QJAAJMEACHWAkAAkwQAIeICAQChBAAh6wIBAKEEACHsAgIAowQAIYQDAQChBAAhpAMgAMYEACGlAwEAogQAIQgDAAC_BAAgzwIAAL4EADDQAgAAAwAQ0QIAAL4EADDiAgIAqQQAIYQDAQChBAAhqAMAAAMAIKkDAAADACAD6AIAAAkAIOkCAAAJACDqAgAACQAgA-gCAAANACDpAgAADQAg6gIAAA0AIAPoAgAAEQAg6QIAABEAIOoCAAARACAD6AIAABsAIOkCAAAbACDqAgAAGwAgCwYAAJQEACASAACVBAAgzwIAAJIEADDQAgAAOwAQ0QIAAJIEADDVAkAAkwQAIdYCQACTBAAh4gIBAKEEACHnAgEAoQQAIagDAAA7ACCpAwAAOwAgAAAAAAAFrQMCAAAAAbMDAgAAAAG0AwIAAAABtQMCAAAAAbYDAgAAAAEBrQNAAAAAAQGtAwEAAAABBSMAAPkHACAkAAD_BwAgqgMAAPoHACCrAwAA_gcAILADAAA0ACAFIwAA9wcAICQAAPwHACCqAwAA-AcAIKsDAAD7BwAgsAMAACYAIAMjAAD5BwAgqgMAAPoHACCwAwAANAAgAyMAAPcHACCqAwAA-AcAILADAAAmACAAAAAAAAGtAyAAAAABBSMAAPEHACAkAAD1BwAgqgMAAPIHACCrAwAA9AcAILADAAC-AwAgCyMAAPcEADAkAAD8BAAwqgMAAPgEADCrAwAA-QQAMKwDAAD6BAAgrQMAAPsEADCuAwAA-wQAMK8DAAD7BAAwsAMAAPsEADCxAwAA_QQAMLIDAAD-BAAwBRUAAO4EACDTAgEAAAAB1AICAAAAAdUCQAAAAAHWAkAAAAABAgAAADAAICMAAIIFACADAAAAMAAgIwAAggUAICQAAIEFACABHAAA8wcAMAsUAADLBAAgFQAAzAQAIM8CAADKBAAw0AIAAC4AENECAADKBAAw0gIBAKEEACHTAgEAoQQAIdQCAgCpBAAh1QJAAJMEACHWAkAAkwQAIaYDAADJBAAgAgAAADAAIBwAAIEFACACAAAA_wQAIBwAAIAFACAIzwIAAP4EADDQAgAA_wQAENECAAD-BAAw0gIBAKEEACHTAgEAoQQAIdQCAgCpBAAh1QJAAJMEACHWAkAAkwQAIQjPAgAA_gQAMNACAAD_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_wJAAAAAAZMDAQAAAAGUAwEAAAABlQMBAAAAAQIAAAALACAjAAD0BgAgAwAAAAsAICMAAPQGACAkAADzBgAgARwAAMEHADAMBgAAlAQAIM8CAADbBAAw0AIAAAkAENECAADbBAAw1QJAAJMEACHWAkAAkwQAIeICAQAAAAHnAgEAoQQAIf8CQACTBAAhkwMBAAAAAZQDAQCiBAAhlQMBAKIEACECAAAACwAgHAAA8wYAIAIAAADxBgAgHAAA8gYAIAvPAgAA8AYAMNACAADxBgAQ0QIAAPAGADDVAkAAkwQAIdYCQACTBAAh4gIBAKEEACHnAgEAoQQAIf8CQACTBAAhkwMBAKEEACGUAwEAogQAIZUDAQCiBAAhC88CAADwBgAw0AIAAPEGABDRAgAA8AYAMNUCQACTBAAh1gJAAJMEACHiAgEAoQQAIecCAQChBAAh_wJAAJMEACGTAwEAoQQAIZQDAQCiBAAhlQMBAKIEACEH1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh_wJAAOkEACGTAwEA6gQAIZQDAQCgBQAhlQMBAKAFACEH1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh_wJAAOkEACGTAwEA6gQAIZQDAQCgBQAhlQMBAKAFACEH1QJAAAAAAdYCQAAAAAHiAgEAAAAB_wJAAAAAAZMDAQAAAAGUAwEAAAABlQMBAAAAAQ0HAAD2BgAgCAAA9wYAIAsAAPgGACAMAAD5BgAgEQAA-gYAIBMAAPsGACDVAkAAAAAB1gJAAAAAAeICAQAAAAHrAgEAAAABhAMBAAAAAaQDIAAAAAGlAwEAAAABBCMAAOkGADCqAwAA6gYAMKwDAADsBgAgsAMAAO0GADAEIwAA3QYAMKoDAADeBgAwrAMAAOAGACCwAwAA4QYAMAQjAADDBgAwqgMAAMQGADCsAwAAxgYAILADAADHBgAwBCMAALcGADCqAwAAuAYAMKwDAAC6BgAgsAMAALsGADAEIwAArgYAMKoDAACvBgAwrAMAALEGACCwAwAA0gUAMAMjAACpBgAgqgMAAKoGACCwAwAAvgMAIAQjAACYBgAwqgMAAJkGADCsAwAAmwYAILADAACcBgAwAAAAAAUjAAC8BwAgJAAAvwcAIKoDAAC9BwAgqwMAAL4HACCwAwAAAQAgAyMAALwHACCqAwAAvQcAILADAAABACAAAAAFIwAAtwcAICQAALoHACCqAwAAuAcAIKsDAAC5BwAgsAMAAAEAIAMjAAC3BwAgqgMAALgHACCwAwAAAQAgAAAABSMAALIHACAkAAC1BwAgqgMAALMHACCrAwAAtAcAILADAAATACADIwAAsgcAIKoDAACzBwAgsAMAABMAIAAAAAcjAACtBwAgJAAAsAcAIKoDAACuBwAgqwMAAK8HACCuAwAABQAgrwMAAAUAILADAAABACADIwAArQcAIKoDAACuBwAgsAMAAAEAIAAAAAAAByMAAKgHACAkAACrBwAgqgMAAKkHACCrAwAAqgcAIK4DAAADACCvAwAAAwAgsAMAALMBACADIwAAqAcAIKoDAACpBwAgsAMAALMBACACBgAAmAUAIBIAAJkFACAAAg0AAJoHACATAACZBwAgBQ4AAJ0HACAPAACeBwAgEAAAnwcAIBEAAKAHACAWAACaBwAgAQ0AAOIFACABDQAA4gUAIAENAADiBQAgAAUGAACYBQAgCgAAogcAIOcCAACaBQAg6wIAAJoFACCjAwAAmgUAIAABAwAA_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_QEAIBwAAOEHACDUAgIA6AQAIeICAgDoBAAhhAMBAOoEACED1AICAOgEACHiAgIA6AQAIYQDAQDqBAAhAwAAAKwCACAjAADWBwAgJAAA5AcAIAUAAACsAgAgHAAA5AcAIOICAgDoBAAhhAMBAOoEACGFAwIA6AQAIQPiAgIA6AQAIYQDAQDqBAAhhQMCAOgEACEF4gIBAAAAAfsCAgAAAAGEAwEAAAABhgMBAAAAAYgDAgAAAAEOBQAAmAcAIAcAAPYGACAIAAD3BgAgCwAA-AYAIBEAAPoGACATAAD7BgAg1QJAAAAAAdYCQAAAAAHiAgEAAAAB6wIBAAAAAewCAgAAAAGEAwEAAAABpAMgAAAAAaUDAQAAAAECAAAAAQAgIwAA5gcAIAMAAAAFACAjAADmBwAgJAAA6gcAIBAAAAAFACAFAACXBwAgBwAAowYAIAgAAKQGACALAAClBgAgEQAApwYAIBMAAKgGACAcAADqBwAg1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh6wIBAOoEACHsAgIAoQUAIYQDAQDqBAAhpAMgAPQEACGlAwEAoAUAIQ4FAACXBwAgBwAAowYAIAgAAKQGACALAAClBgAgEQAApwYAIBMAAKgGACDVAkAA6QQAIdYCQADpBAAh4gIBAOoEACHrAgEA6gQAIewCAgChBQAhhAMBAOoEACGkAyAA9AQAIaUDAQCgBQAhDgUAAJgHACAHAAD2BgAgCAAA9wYAIAsAAPgGACAMAAD5BgAgEQAA-gYAINUCQAAAAAHWAkAAAAAB4gIBAAAAAesCAQAAAAHsAgIAAAABhAMBAAAAAaQDIAAAAAGlAwEAAAABAgAAAAEAICMAAOsHACAG1QJAAAAAAdYCQAAAAAHiAgEAAAAB5AICAAAAAeUCAgAAAAHmAiAAAAABAwAAAAUAICMAAOsHACAkAADwBwAgEAAAAAUAIAUAAJcHACAHAACjBgAgCAAApAYAIAsAAKUGACAMAACmBgAgEQAApwYAIBwAAPAHACDVAkAA6QQAIdYCQADpBAAh4gIBAOoEACHrAgEA6gQAIewCAgChBQAhhAMBAOoEACGkAyAA9AQAIaUDAQCgBQAhDgUAAJcHACAHAACjBgAgCAAApAYAIAsAAKUGACAMAACmBgAgEQAApwYAINUCQADpBAAh1gJAAOkEACHiAgEA6gQAIesCAQDqBAAh7AICAKEFACGEAwEA6gQAIaQDIAD0BAAhpQMBAKAFACEFBgAAlgUAINUCQAAAAAHWAkAAAAAB4gIBAAAAAecCAQAAAAECAAAAvgMAICMAAPEHACAE0wIBAAAAAdQCAgAAAAHVAkAAAAAB1gJAAAAAAQMAAAA7ACAjAADxBwAgJAAA9gcAIAcAAAA7ACAGAACIBQAgHAAA9gcAINUCQADpBAAh1gJAAOkEACHiAgEA6gQAIecCAQDqBAAhBQYAAIgFACDVAkAA6QQAIdYCQADpBAAh4gIBAOoEACHnAgEA6gQAIQoOAADdBQAgDwAA8wUAIBAAAN4FACARAADfBQAg4gIBAAAAAfsCAgAAAAGEAwEAAAABhgMBAAAAAYcDAgAAAAGIAwIAAAABAgAAACYAICMAAPcHACAIEwAAgwUAINUCQAAAAAHWAkAAAAAB4gIBAAAAAeMCAQAAAAHkAgIAAAAB5QICAAAAAeYCIAAAAAECAAAANAAgIwAA-QcAIAMAAAAkACAjAAD3BwAgJAAA_QcAIAwAAAAkACAOAADBBQAgDwAA8QUAIBAAAMIFACARAADDBQAgHAAA_QcAIOICAQDqBAAh-wICAOgEACGEAwEA6gQAIYYDAQDqBAAhhwMCAOgEACGIAwIA6AQAIQoOAADBBQAgDwAA8QUAIBAAAMIFACARAADDBQAg4gIBAOoEACH7AgIA6AQAIYQDAQDqBAAhhgMBAOoEACGHAwIA6AQAIYgDAgDoBAAhAwAAADIAICMAAPkHACAkAACACAAgCgAAADIAIBMAAPUEACAcAACACAAg1QJAAOkEACHWAkAA6QQAIeICAQDqBAAh4wIBAOoEACHkAgIA6AQAIeUCAgDoBAAh5gIgAPQEACEIEwAA9QQAINUCQADpBAAh1gJAAOkEACHiAgEA6gQAIeMCAQDqBAAh5AICAOgEACHlAgIA6AQAIeYCIAD0BAAhCAQAGAUEAgcMBAgQBQsUBgweCREjChM8FAIDBwEEAAMBAwgAAQYAAQEGAAEDBAAIBhUBChkHAQkABgEKGgABBh8BAgYAARUACwYEABcOAAwPAA4QABARLQoWMRICBAANDScLAQ0oAAIEAA8NKQsBDSoAAgQAEQ0rCwENLAACFAATFQALAwQAFg03EhMAFAMEABUGAAESNRMBEjYAAQ04AAIROQAWOgAFBz0ACD4ACz8ADEAAEUEAAAEFSwIBBVECBQQAHSkAHioAHysAICwAIQAAAAAABQQAHSkAHioAHysAICwAIQEGYwEBBmkBAwQAJisAJywAKAAAAAMEACYrACcsACgBCQAGAQkABgMEAC0rAC4sAC8AAAADBAAtKwAuLAAvAQYAAQEGAAEDBAA0KwA1LAA2AAAAAwQANCsANSwANgEGAAEBBgABAwQAOysAPCwAPQAAAAMEADsrADwsAD0AAAUEAEIpAEMqAEQrAEUsAEYAAAAAAAUEAEIpAEMqAEQrAEUsAEYAAAADBABMKwBNLABOAAAAAwQATCsATSwATgMOAAwPAA4QABADDgAMDwAOEAAQBQQAUykAVCoAVSsAViwAVwAAAAAABQQAUykAVCoAVSsAViwAVwAABQQAXCkAXSoAXisAXywAYAAAAAAABQQAXCkAXSoAXisAXywAYAIGAAEVAAsCBgABFQALAwQAZSsAZiwAZwAAAAMEAGUrAGYsAGcAAAUEAGwpAG0qAG4rAG8sAHAAAAAAAAUEAGwpAG0qAG4rAG8sAHAAAAUEAHUpAHYqAHcrAHgsAHkAAAAAAAUEAHUpAHYqAHcrAHgsAHkBBuUCAQEG6wIBAwQAfisAfywAgAEAAAADBAB-KwB_LACAAQAAAAMEAIYBKwCHASwAiAEAAAADBACGASsAhwEsAIgBAAAABQQAjgEpAI8BKgCQASsAkQEsAJIBAAAAAAAFBACOASkAjwEqAJABKwCRASwAkgEAAAAFBACYASkAmQEqAJoBKwCbASwAnAEAAAAAAAUEAJgBKQCZASoAmgErAJsBLACcAQEGAAEBBgABAwQAoQErAKIBLACjAQAAAAMEAKEBKwCiASwAowEBEwAUARMAFAUEAKgBKQCpASoAqgErAKsBLACsAQAAAAAABQQAqAEpAKkBKgCqASsAqwEsAKwBAhQAExUACwIUABMVAAsFBACxASkAsgEqALMBKwC0ASwAtQEAAAAAAAUEALEBKQCyASoAswErALQBLAC1ARcCARhCARlDARpEARtFAR1HAR5JGR9KGiBNASFPGSJQGyVSASZTASdUGS1XHC5YIi9ZBjBaBjFbBjJcBjNdBjRfBjVhGTZiIzdlBjhnGTloJDpqBjtrBjxsGT1vJT5wKT9xB0ByB0FzB0J0B0N1B0R3B0V5GUZ6Kkd8B0h-GUl_K0qAAQdLgQEHTIIBGU2FASxOhgEwT4cBBFCIAQRRiQEEUooBBFOLAQRUjQEEVY8BGVaQATFXkgEEWJQBGVmVATJalgEEW5cBBFyYARldmwEzXpwBN1-dAQVgngEFYZ8BBWKgAQVjoQEFZKMBBWWlARlmpgE4Z6gBBWiqARlpqwE5aqwBBWutAQVsrgEZbbEBOm6yAT5vtAECcLUBAnG3AQJyuAECc7kBAnS7AQJ1vQEZdr4BP3fAAQJ4wgEZecMBQHrEAQJ7xQECfMYBGX3JAUF-ygFHf8wBSIABzQFIgQHQAUiCAdEBSIMB0gFIhAHUAUiFAdYBGYYB1wFJhwHZAUiIAdsBGYkB3AFKigHdAUiLAd4BSIwB3wEZjQHiAUuOAeMBT48B5AELkAHlAQuRAeYBC5IB5wELkwHoAQuUAeoBC5UB7AEZlgHtAVCXAe8BC5gB8QEZmQHyAVGaAfMBC5sB9AELnAH1ARmdAfgBUp4B-QFYnwH7ARCgAfwBEKEB_wEQogGAAhCjAYECEKQBgwIQpQGFAhmmAYYCWacBiAIQqAGKAhmpAYsCWqoBjAIQqwGNAhCsAY4CGa0BkQJbrgGSAmGvAZMCCrABlAIKsQGVAgqyAZYCCrMBlwIKtAGZAgq1AZsCGbYBnAJitwGeAgq4AaACGbkBoQJjugGiAgq7AaMCCrwBpAIZvQGnAmS-AagCaL8BqgIMwAGrAgzBAa4CDMIBrwIMwwGwAgzEAbICDMUBtAIZxgG1AmnHAbcCDMgBuQIZyQG6AmrKAbsCDMsBvAIMzAG9AhnNAcACa84BwQJxzwHDAg7QAcQCDtEBxwIO0gHIAg7TAckCDtQBywIO1QHNAhnWAc4CctcB0AIO2AHSAhnZAdMCc9oB1AIO2wHVAg7cAdYCGd0B2QJ03gHaAnrfAdsCCeAB3AIJ4QHdAgniAd4CCeMB3wIJ5AHhAgnlAeMCGeYB5AJ75wHnAgnoAekCGekB6gJ86gHsAgnrAe0CCewB7gIZ7QHxAn3uAfICgQHvAfQCggHwAfUCggHxAfgCggHyAfkCggHzAfoCggH0AfwCggH1Af4CGfYB_wKDAfcBgQOCAfgBgwMZ-QGEA4QB-gGFA4IB-wGGA4IB_AGHAxn9AYoDhQH-AYsDiQH_AY0DigGAAo4DigGBApEDigGCApIDigGDApMDigGEApUDigGFApcDGYYCmAOLAYcCmgOKAYgCnAMZiQKdA4wBigKeA4oBiwKfA4oBjAKgAxmNAqMDjQGOAqQDkwGPAqYDlAGQAqcDlAGRAqoDlAGSAqsDlAGTAqwDlAGUAq4DlAGVArADGZYCsQOVAZcCswOUAZgCtQMZmQK2A5YBmgK3A5QBmwK4A5QBnAK5AxmdArwDlwGeAr0DnQGfAr8DFKACwAMUoQLCAxSiAsMDFKMCxAMUpALGAxSlAsgDGaYCyQOeAacCywMUqALNAxmpAs4DnwGqAs8DFKsC0AMUrALRAxmtAtQDoAGuAtUDpAGvAtYDE7AC1wMTsQLYAxOyAtkDE7MC2gMTtALcAxO1At4DGbYC3wOlAbcC4QMTuALjAxm5AuQDpgG6AuUDE7sC5gMTvALnAxm9AuoDpwG-AusDrQG_AuwDEsAC7QMSwQLuAxLCAu8DEsMC8AMSxALyAxLFAvQDGcYC9QOuAccC9wMSyAL5AxnJAvoDrwHKAvsDEssC_AMSzAL9AxnNAoAEsAHOAoEEtgE" + strings: JSON.parse("[\"where\",\"orderBy\",\"cursor\",\"users\",\"_count\",\"role\",\"user\",\"sessions\",\"accounts\",\"profile\",\"addresses\",\"profiles\",\"licenseKey\",\"schedule\",\"block\",\"proclamations\",\"solfeggioFrequency\",\"category\",\"group\",\"proclamation\",\"userProclamations\",\"blockProclamations\",\"blocks\",\"licenseKeys\",\"schedules\",\"User.findUnique\",\"User.findUniqueOrThrow\",\"User.findFirst\",\"User.findFirstOrThrow\",\"User.findMany\",\"data\",\"User.createOne\",\"User.createMany\",\"User.createManyAndReturn\",\"User.updateOne\",\"User.updateMany\",\"User.updateManyAndReturn\",\"create\",\"update\",\"User.upsertOne\",\"User.deleteOne\",\"User.deleteMany\",\"having\",\"_avg\",\"_sum\",\"_min\",\"_max\",\"User.groupBy\",\"User.aggregate\",\"Profile.findUnique\",\"Profile.findUniqueOrThrow\",\"Profile.findFirst\",\"Profile.findFirstOrThrow\",\"Profile.findMany\",\"Profile.createOne\",\"Profile.createMany\",\"Profile.createManyAndReturn\",\"Profile.updateOne\",\"Profile.updateMany\",\"Profile.updateManyAndReturn\",\"Profile.upsertOne\",\"Profile.deleteOne\",\"Profile.deleteMany\",\"Profile.groupBy\",\"Profile.aggregate\",\"Address.findUnique\",\"Address.findUniqueOrThrow\",\"Address.findFirst\",\"Address.findFirstOrThrow\",\"Address.findMany\",\"Address.createOne\",\"Address.createMany\",\"Address.createManyAndReturn\",\"Address.updateOne\",\"Address.updateMany\",\"Address.updateManyAndReturn\",\"Address.upsertOne\",\"Address.deleteOne\",\"Address.deleteMany\",\"Address.groupBy\",\"Address.aggregate\",\"Session.findUnique\",\"Session.findUniqueOrThrow\",\"Session.findFirst\",\"Session.findFirstOrThrow\",\"Session.findMany\",\"Session.createOne\",\"Session.createMany\",\"Session.createManyAndReturn\",\"Session.updateOne\",\"Session.updateMany\",\"Session.updateManyAndReturn\",\"Session.upsertOne\",\"Session.deleteOne\",\"Session.deleteMany\",\"Session.groupBy\",\"Session.aggregate\",\"Account.findUnique\",\"Account.findUniqueOrThrow\",\"Account.findFirst\",\"Account.findFirstOrThrow\",\"Account.findMany\",\"Account.createOne\",\"Account.createMany\",\"Account.createManyAndReturn\",\"Account.updateOne\",\"Account.updateMany\",\"Account.updateManyAndReturn\",\"Account.upsertOne\",\"Account.deleteOne\",\"Account.deleteMany\",\"Account.groupBy\",\"Account.aggregate\",\"UserRole.findUnique\",\"UserRole.findUniqueOrThrow\",\"UserRole.findFirst\",\"UserRole.findFirstOrThrow\",\"UserRole.findMany\",\"UserRole.createOne\",\"UserRole.createMany\",\"UserRole.createManyAndReturn\",\"UserRole.updateOne\",\"UserRole.updateMany\",\"UserRole.updateManyAndReturn\",\"UserRole.upsertOne\",\"UserRole.deleteOne\",\"UserRole.deleteMany\",\"UserRole.groupBy\",\"UserRole.aggregate\",\"Verification.findUnique\",\"Verification.findUniqueOrThrow\",\"Verification.findFirst\",\"Verification.findFirstOrThrow\",\"Verification.findMany\",\"Verification.createOne\",\"Verification.createMany\",\"Verification.createManyAndReturn\",\"Verification.updateOne\",\"Verification.updateMany\",\"Verification.updateManyAndReturn\",\"Verification.upsertOne\",\"Verification.deleteOne\",\"Verification.deleteMany\",\"Verification.groupBy\",\"Verification.aggregate\",\"Proclamation.findUnique\",\"Proclamation.findUniqueOrThrow\",\"Proclamation.findFirst\",\"Proclamation.findFirstOrThrow\",\"Proclamation.findMany\",\"Proclamation.createOne\",\"Proclamation.createMany\",\"Proclamation.createManyAndReturn\",\"Proclamation.updateOne\",\"Proclamation.updateMany\",\"Proclamation.updateManyAndReturn\",\"Proclamation.upsertOne\",\"Proclamation.deleteOne\",\"Proclamation.deleteMany\",\"Proclamation.groupBy\",\"Proclamation.aggregate\",\"ProclamationGroup.findUnique\",\"ProclamationGroup.findUniqueOrThrow\",\"ProclamationGroup.findFirst\",\"ProclamationGroup.findFirstOrThrow\",\"ProclamationGroup.findMany\",\"ProclamationGroup.createOne\",\"ProclamationGroup.createMany\",\"ProclamationGroup.createManyAndReturn\",\"ProclamationGroup.updateOne\",\"ProclamationGroup.updateMany\",\"ProclamationGroup.updateManyAndReturn\",\"ProclamationGroup.upsertOne\",\"ProclamationGroup.deleteOne\",\"ProclamationGroup.deleteMany\",\"ProclamationGroup.groupBy\",\"ProclamationGroup.aggregate\",\"UserProclamations.findUnique\",\"UserProclamations.findUniqueOrThrow\",\"UserProclamations.findFirst\",\"UserProclamations.findFirstOrThrow\",\"UserProclamations.findMany\",\"UserProclamations.createOne\",\"UserProclamations.createMany\",\"UserProclamations.createManyAndReturn\",\"UserProclamations.updateOne\",\"UserProclamations.updateMany\",\"UserProclamations.updateManyAndReturn\",\"UserProclamations.upsertOne\",\"UserProclamations.deleteOne\",\"UserProclamations.deleteMany\",\"UserProclamations.groupBy\",\"UserProclamations.aggregate\",\"SolfeggioFrequency.findUnique\",\"SolfeggioFrequency.findUniqueOrThrow\",\"SolfeggioFrequency.findFirst\",\"SolfeggioFrequency.findFirstOrThrow\",\"SolfeggioFrequency.findMany\",\"SolfeggioFrequency.createOne\",\"SolfeggioFrequency.createMany\",\"SolfeggioFrequency.createManyAndReturn\",\"SolfeggioFrequency.updateOne\",\"SolfeggioFrequency.updateMany\",\"SolfeggioFrequency.updateManyAndReturn\",\"SolfeggioFrequency.upsertOne\",\"SolfeggioFrequency.deleteOne\",\"SolfeggioFrequency.deleteMany\",\"SolfeggioFrequency.groupBy\",\"SolfeggioFrequency.aggregate\",\"ProclamationCategory.findUnique\",\"ProclamationCategory.findUniqueOrThrow\",\"ProclamationCategory.findFirst\",\"ProclamationCategory.findFirstOrThrow\",\"ProclamationCategory.findMany\",\"ProclamationCategory.createOne\",\"ProclamationCategory.createMany\",\"ProclamationCategory.createManyAndReturn\",\"ProclamationCategory.updateOne\",\"ProclamationCategory.updateMany\",\"ProclamationCategory.updateManyAndReturn\",\"ProclamationCategory.upsertOne\",\"ProclamationCategory.deleteOne\",\"ProclamationCategory.deleteMany\",\"ProclamationCategory.groupBy\",\"ProclamationCategory.aggregate\",\"LicenseKey.findUnique\",\"LicenseKey.findUniqueOrThrow\",\"LicenseKey.findFirst\",\"LicenseKey.findFirstOrThrow\",\"LicenseKey.findMany\",\"LicenseKey.createOne\",\"LicenseKey.createMany\",\"LicenseKey.createManyAndReturn\",\"LicenseKey.updateOne\",\"LicenseKey.updateMany\",\"LicenseKey.updateManyAndReturn\",\"LicenseKey.upsertOne\",\"LicenseKey.deleteOne\",\"LicenseKey.deleteMany\",\"LicenseKey.groupBy\",\"LicenseKey.aggregate\",\"ApiKey.findUnique\",\"ApiKey.findUniqueOrThrow\",\"ApiKey.findFirst\",\"ApiKey.findFirstOrThrow\",\"ApiKey.findMany\",\"ApiKey.createOne\",\"ApiKey.createMany\",\"ApiKey.createManyAndReturn\",\"ApiKey.updateOne\",\"ApiKey.updateMany\",\"ApiKey.updateManyAndReturn\",\"ApiKey.upsertOne\",\"ApiKey.deleteOne\",\"ApiKey.deleteMany\",\"ApiKey.groupBy\",\"ApiKey.aggregate\",\"ColorPalette.findUnique\",\"ColorPalette.findUniqueOrThrow\",\"ColorPalette.findFirst\",\"ColorPalette.findFirstOrThrow\",\"ColorPalette.findMany\",\"ColorPalette.createOne\",\"ColorPalette.createMany\",\"ColorPalette.createManyAndReturn\",\"ColorPalette.updateOne\",\"ColorPalette.updateMany\",\"ColorPalette.updateManyAndReturn\",\"ColorPalette.upsertOne\",\"ColorPalette.deleteOne\",\"ColorPalette.deleteMany\",\"ColorPalette.groupBy\",\"ColorPalette.aggregate\",\"AuditLog.findUnique\",\"AuditLog.findUniqueOrThrow\",\"AuditLog.findFirst\",\"AuditLog.findFirstOrThrow\",\"AuditLog.findMany\",\"AuditLog.createOne\",\"AuditLog.createMany\",\"AuditLog.createManyAndReturn\",\"AuditLog.updateOne\",\"AuditLog.updateMany\",\"AuditLog.updateManyAndReturn\",\"AuditLog.upsertOne\",\"AuditLog.deleteOne\",\"AuditLog.deleteMany\",\"AuditLog.groupBy\",\"AuditLog.aggregate\",\"Schedule.findUnique\",\"Schedule.findUniqueOrThrow\",\"Schedule.findFirst\",\"Schedule.findFirstOrThrow\",\"Schedule.findMany\",\"Schedule.createOne\",\"Schedule.createMany\",\"Schedule.createManyAndReturn\",\"Schedule.updateOne\",\"Schedule.updateMany\",\"Schedule.updateManyAndReturn\",\"Schedule.upsertOne\",\"Schedule.deleteOne\",\"Schedule.deleteMany\",\"Schedule.groupBy\",\"Schedule.aggregate\",\"Block.findUnique\",\"Block.findUniqueOrThrow\",\"Block.findFirst\",\"Block.findFirstOrThrow\",\"Block.findMany\",\"Block.createOne\",\"Block.createMany\",\"Block.createManyAndReturn\",\"Block.updateOne\",\"Block.updateMany\",\"Block.updateManyAndReturn\",\"Block.upsertOne\",\"Block.deleteOne\",\"Block.deleteMany\",\"Block.groupBy\",\"Block.aggregate\",\"BlockProclamations.findUnique\",\"BlockProclamations.findUniqueOrThrow\",\"BlockProclamations.findFirst\",\"BlockProclamations.findFirstOrThrow\",\"BlockProclamations.findMany\",\"BlockProclamations.createOne\",\"BlockProclamations.createMany\",\"BlockProclamations.createManyAndReturn\",\"BlockProclamations.updateOne\",\"BlockProclamations.updateMany\",\"BlockProclamations.updateManyAndReturn\",\"BlockProclamations.upsertOne\",\"BlockProclamations.deleteOne\",\"BlockProclamations.deleteMany\",\"BlockProclamations.groupBy\",\"BlockProclamations.aggregate\",\"AND\",\"OR\",\"NOT\",\"blockId\",\"proclamationId\",\"sortOrder\",\"createdAt\",\"updatedAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"contains\",\"startsWith\",\"endsWith\",\"id\",\"scheduleId\",\"day\",\"startMinutes\",\"endMinutes\",\"dynamicAudio\",\"licenseKeyId\",\"userId\",\"type\",\"email\",\"roleId\",\"roleName\",\"page\",\"action\",\"entity\",\"entityId\",\"before\",\"after\",\"ip\",\"string_contains\",\"string_starts_with\",\"string_ends_with\",\"array_starts_with\",\"array_ends_with\",\"array_contains\",\"solfeggioFrequencyId\",\"colors\",\"keyPrefix\",\"keyHash\",\"expiresAt\",\"revokedAt\",\"key\",\"active\",\"lastValidatedAt\",\"systemName\",\"systemNameLower\",\"name\",\"every\",\"some\",\"none\",\"value\",\"content\",\"categoryId\",\"groupId\",\"identifier\",\"accountId\",\"providerId\",\"accessToken\",\"refreshToken\",\"idToken\",\"accessTokenExpiresAt\",\"refreshTokenExpiresAt\",\"scope\",\"password\",\"token\",\"ipAddress\",\"userAgent\",\"profileId\",\"street\",\"city\",\"state\",\"postalCode\",\"country\",\"isDefaultMailing\",\"isDefaultBilling\",\"isDefaultShipping\",\"firstName\",\"lastName\",\"phoneNumber\",\"ghlContactId\",\"emailVerified\",\"image\",\"userId_proclamationId\",\"blockId_proclamationId\",\"userId_systemNameLower\",\"is\",\"isNot\",\"connectOrCreate\",\"upsert\",\"createMany\",\"set\",\"disconnect\",\"delete\",\"connect\",\"updateMany\",\"deleteMany\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"), + graph: "owi2AbACEgUAAOgEACAHAADpBAAgCAAA6gQAIAsAAOsEACAUAADWBAAgFwAA7AQAIBgAAO0EACDRAgAA5wQAMNICAAAFABDTAgAA5wQAMNcCQACmBAAh2AJAAKYEACHkAgEAAAAB7QIBAAAAAe4CAgCoBAAhiAMBAKUEACGqAyAA3AQAIasDAQCnBAAhAQAAAAEAIAYDAADEBAAg0QIAAMMEADDSAgAAAwAQ0wIAAMMEADDkAgIArgQAIYgDAQClBAAhAQAAAAMAIBIFAADoBAAgBwAA6QQAIAgAAOoEACALAADrBAAgFAAA1gQAIBcAAOwEACAYAADtBAAg0QIAAOcEADDSAgAABQAQ0wIAAOcEADDXAkAApgQAIdgCQACmBAAh5AIBAKUEACHtAgEApQQAIe4CAgCoBAAhiAMBAKUEACGqAyAA3AQAIasDAQCnBAAhCQUAAL8HACAHAADABwAgCAAAwQcAIAsAAMIHACAUAAC5BwAgFwAAwwcAIBgAAMQHACDuAgAA-gQAIKsDAAD6BAAgAwAAAAUAIAEAAAYAMAIAAAEAIAEAAAAFACAMBgAA0AQAINECAADmBAAw0gIAAAkAENMCAADmBAAw1wJAAKYEACHYAkAApgQAIeQCAQClBAAh6wIBAKUEACGBA0AApgQAIZoDAQClBAAhmwMBAKcEACGcAwEApwQAIQMGAACzBwAgmwMAAPoEACCcAwAA-gQAIAwGAADQBAAg0QIAAOYEADDSAgAACQAQ0wIAAOYEADDXAkAApgQAIdgCQACmBAAh5AIBAAAAAesCAQClBAAhgQNAAKYEACGaAwEAAAABmwMBAKcEACGcAwEApwQAIQMAAAAJACABAAAKADACAAALACARBgAA0AQAINECAADlBAAw0gIAAA0AENMCAADlBAAw1wJAAKYEACHYAkAApgQAIeQCAQClBAAh6wIBAKUEACGRAwEApQQAIZIDAQClBAAhkwMBAKcEACGUAwEApwQAIZUDAQCnBAAhlgNAALUEACGXA0AAtQQAIZgDAQCnBAAhmQMBAKcEACEIBgAAswcAIJMDAAD6BAAglAMAAPoEACCVAwAA-gQAIJYDAAD6BAAglwMAAPoEACCYAwAA-gQAIJkDAAD6BAAgEQYAANAEACDRAgAA5QQAMNICAAANABDTAgAA5QQAMNcCQACmBAAh2AJAAKYEACHkAgEAAAAB6wIBAKUEACGRAwEApQQAIZIDAQClBAAhkwMBAKcEACGUAwEApwQAIZUDAQCnBAAhlgNAALUEACGXA0AAtQQAIZgDAQCnBAAhmQMBAKcEACEDAAAADQAgAQAADgAwAgAADwAgDAYAAMwEACAKAADkBAAg0QIAAOMEADDSAgAAEQAQ0wIAAOMEADDkAgEApQQAIesCAQCnBAAh7QIBAKcEACGmAwEApQQAIacDAQClBAAhqAMBAKUEACGpAwEApwQAIQUGAACzBwAgCgAAvgcAIOsCAAD6BAAg7QIAAPoEACCpAwAA-gQAIAwGAADMBAAgCgAA5AQAINECAADjBAAw0gIAABEAENMCAADjBAAw5AIBAAAAAesCAQCnBAAh7QIBAKcEACGmAwEApQQAIacDAQClBAAhqAMBAKUEACGpAwEApwQAIQMAAAARACABAAASADACAAATACABAAAABQAgDwkAAOIEACDRAgAA4QQAMNICAAAWABDTAgAA4QQAMOQCAQClBAAh7AIBAKUEACGdAwEApQQAIZ4DAQClBAAhnwMBAKUEACGgAwEApQQAIaEDAQClBAAhogMBAKUEACGjAyAA3AQAIaQDIADcBAAhpQMgANwEACEBCQAAvQcAIA8JAADiBAAg0QIAAOEEADDSAgAAFgAQ0wIAAOEEADDkAgEAAAAB7AIBAKUEACGdAwEApQQAIZ4DAQClBAAhnwMBAKUEACGgAwEApQQAIaEDAQClBAAhogMBAKUEACGjAyAA3AQAIaQDIADcBAAhpQMgANwEACEDAAAAFgAgAQAAFwAwAgAAGAAgAQAAABYAIA4GAADMBAAgDQAA4AQAINECAADfBAAw0gIAABsAENMCAADfBAAw1wJAAKYEACHkAgEApQQAIesCAQCnBAAh7QIBAKcEACGDAwEApQQAIYQDIADcBAAhhQNAALUEACGGAwEApwQAIYcDAQCnBAAhBwYAALMHACANAAC8BwAg6wIAAPoEACDtAgAA-gQAIIUDAAD6BAAghgMAAPoEACCHAwAA-gQAIA8GAADMBAAgDQAA4AQAINECAADfBAAw0gIAABsAENMCAADfBAAw1wJAAKYEACHkAgEAAAAB6wIBAKcEACHtAgEApwQAIYMDAQAAAAGEAyAA3AQAIYUDQAC1BAAhhgMBAKcEACGHAwEApwQAIa4DAADeBAAgAwAAABsAIAEAABwAMAIAAB0AIAEAAAAFACAMBgAAzAQAIAwAAMsEACAWAADNBAAg0QIAAMoEADDSAgAAIAAQ0wIAAMoEADDXAkAApgQAIdgCQACmBAAh5AIBAKUEACHqAgEApwQAIesCAQCnBAAh7AIBAKcEACEBAAAAIAAgAQAAABsAIAEAAAAFACANDQAA3QQAIA8AANcEACDRAgAA2wQAMNICAAAkABDTAgAA2wQAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIeUCAQClBAAh5gIBAKcEACHnAgIArgQAIegCAgCuBAAh6QIgANwEACEDDQAAvAcAIA8AALoHACDmAgAA-gQAIA0NAADdBAAgDwAA1wQAINECAADbBAAw0gIAACQAENMCAADbBAAw1wJAAKYEACHYAkAApgQAIeQCAQAAAAHlAgEApQQAIeYCAQCnBAAh5wICAK4EACHoAgIArgQAIekCIADcBAAhAwAAACQAIAEAACUAMAIAACYAIAoOAADaBAAgEwAA0QQAINECAADZBAAw0gIAACgAENMCAADZBAAw1AIBAKUEACHVAgEApQQAIdYCAgCuBAAh1wJAAKYEACHYAkAApgQAIQIOAAC7BwAgEwAAtQcAIAsOAADaBAAgEwAA0QQAINECAADZBAAw0gIAACgAENMCAADZBAAw1AIBAKUEACHVAgEApQQAIdYCAgCuBAAh1wJAAKYEACHYAkAApgQAIa0DAADYBAAgAwAAACgAIAEAACkAMAIAACoAIA4QAADTBAAgEQAA1AQAIBIAANUEACAUAADWBAAgFQAA1wQAINECAADSBAAw0gIAACwAENMCAADSBAAw5AIBAKUEACH9AgIArgQAIYgDAQClBAAhjQMBAKUEACGOAwIArgQAIY8DAgCuBAAhBRAAALYHACARAAC3BwAgEgAAuAcAIBQAALkHACAVAAC6BwAgDhAAANMEACARAADUBAAgEgAA1QQAIBQAANYEACAVAADXBAAg0QIAANIEADDSAgAALAAQ0wIAANIEADDkAgEAAAAB_QICAK4EACGIAwEAAAABjQMBAKUEACGOAwIArgQAIY8DAgCuBAAhAwAAACwAIAEAAC0AMAIAAC4AIAEAAAAsACADAAAALAAgAQAALQAwAgAALgAgAQAAACwAIAMAAAAsACABAAAtADACAAAuACABAAAALAAgCQYAANAEACATAADRBAAg0QIAAM8EADDSAgAANQAQ0wIAAM8EADDVAgEApQQAIdcCQACmBAAh2AJAAKYEACHrAgEApQQAIQIGAACzBwAgEwAAtQcAIAoGAADQBAAgEwAA0QQAINECAADPBAAw0gIAADUAENMCAADPBAAw1QIBAKUEACHXAkAApgQAIdgCQACmBAAh6wIBAKUEACGsAwAAzgQAIAMAAAA1ACABAAA2ADACAAA3ACADAAAAKAAgAQAAKQAwAgAAKgAgAQAAADUAIAEAAAAoACABAAAAKAAgAQAAACQAIAMAAAA1ACABAAA2ADACAAA3ACAGBgAAswcAIAwAALIHACAWAAC0BwAg6gIAAPoEACDrAgAA-gQAIOwCAAD6BAAgDAYAAMwEACAMAADLBAAgFgAAzQQAINECAADKBAAw0gIAACAAENMCAADKBAAw1wJAAKYEACHYAkAApgQAIeQCAQAAAAHqAgEAAAAB6wIBAKcEACHsAgEApwQAIQMAAAAgACABAAA_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-gQAIJQDAAD6BAAglQMAAPoEACCWAwAA-gQAIJcDAAD6BAAgmAMAAPoEACCZAwAA-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_QEAENMCAAC_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-gQAIO0CAAD6BAAghQMAAPoEACCGAwAA-gQAIIcDAAD6BAAgDNECAAC2BAAw0gIAAPYCABDTAgAAtgQAMNcCQACNBAAh5AIBAIsEACHrAgEAlgQAIe0CAQCWBAAhgwMBAIsEACGEAyAAlwQAIYUDQACxBAAhhgMBAJYEACGHAwEAlgQAIQMAAAAbACABAAD1AgAwKgAA9gIAIAMAAAAbACABAAAcADACAAAdACAJ0QIAALQEADDSAgAA_AIAENMCAAC0BAAw1wJAAKYEACHkAgEAAAAB_wIBAKUEACGAAwEAAAABgQNAALUEACGCA0AAtQQAIQEAAAD5AgAgAQAAAPkCACAJ0QIAALQEADDSAgAA_AIAENMCAAC0BAAw1wJAAKYEACHkAgEApQQAIf8CAQClBAAhgAMBAKUEACGBA0AAtQQAIYIDQAC1BAAhAoEDAAD6BAAgggMAAPoEACADAAAA_AIAIAEAAP0CADACAAD5AgAgAwAAAPwCACABAAD9AgAwAgAA-QIAIAMAAAD8AgAgAQAA_QIAMAIAAPkCACAG1wJAAAAAAeQCAQAAAAH_AgEAAAABgAMBAAAAAYEDQAAAAAGCA0AAAAABAR4AAIEDACAG1wJAAAAAAeQCAQAAAAH_AgEAAAABgAMBAAAAAYEDQAAAAAGCA0AAAAABAR4AAIMDADABHgAAgwMAMAbXAkAA9AQAIeQCAQD1BAAh_wIBAPUEACGAAwEA9QQAIYEDQAC1BQAhggNAALUFACECAAAA-QIAIB4AAIYDACAG1wJAAPQEACHkAgEA9QQAIf8CAQD1BAAhgAMBAPUEACGBA0AAtQUAIYIDQAC1BQAhAgAAAPwCACAeAACIAwAgAgAAAPwCACAeAACIAwAgAwAAAPkCACAlAACBAwAgJgAAhgMAIAEAAAD5AgAgAQAAAPwCACAFBAAAsgUAIC0AALQFACAuAACzBQAggQMAAPoEACCCAwAA-gQAIAnRAgAAsAQAMNICAACPAwAQ0wIAALAEADDXAkAAjQQAIeQCAQCLBAAh_wIBAIsEACGAAwEAiwQAIYEDQACxBAAhggNAALEEACEDAAAA_AIAIAEAAI4DADAqAACPAwAgAwAAAPwCACABAAD9AgAwAgAA-QIAIAXRAgAArQQAMNICAACVAwAQ0wIAAK0EADD9AgIAAAAB_gIAAK8EACABAAAAkgMAIAEAAACSAwAgBdECAACtBAAw0gIAAJUDABDTAgAArQQAMP0CAgCuBAAh_gIAAK8EACAAAwAAAJUDACABAACWAwAwAgAAkgMAIAMAAACVAwAgAQAAlgMAMAIAAJIDACADAAAAlQMAIAEAAJYDADACAACSAwAgAv0CAgAAAAH-AoAAAAABAR4AAJoDACAC_QICAAAAAf4CgAAAAAEBHgAAnAMAMAEeAACcAwAwAv0CAgDzBAAh_gKAAAAAAQIAAACSAwAgHgAAnwMAIAL9AgIA8wQAIf4CgAAAAAECAAAAlQMAIB4AAKEDACACAAAAlQMAIB4AAKEDACADAAAAkgMAICUAAJoDACAmAACfAwAgAQAAAJIDACABAAAAlQMAIAUEAACtBQAgKwAArgUAICwAALEFACAtAACwBQAgLgAArwUAIAXRAgAAqgQAMNICAACoAwAQ0wIAAKoEADD9AgIAjAQAIf4CAACrBAAgAwAAAJUDACABAACnAwAwKgAAqAMAIAMAAACVAwAgAQAAlgMAMAIAAJIDACAQ0QIAAKQEADDSAgAArgMAENMCAACkBAAw1wJAAKYEACHkAgEAAAAB6wIBAKcEACHtAgEApwQAIe4CAgCoBAAh7wIBAKcEACHwAgEApQQAIfECAQClBAAh8gIBAKcEACHzAgEApwQAIfQCAACpBAAg9QIAAKkEACD2AgEApwQAIQEAAACrAwAgAQAAAKsDACAQ0QIAAKQEADDSAgAArgMAENMCAACkBAAw1wJAAKYEACHkAgEApQQAIesCAQCnBAAh7QIBAKcEACHuAgIAqAQAIe8CAQCnBAAh8AIBAKUEACHxAgEApQQAIfICAQCnBAAh8wIBAKcEACH0AgAAqQQAIPUCAACpBAAg9gIBAKcEACEJ6wIAAPoEACDtAgAA-gQAIO4CAAD6BAAg7wIAAPoEACDyAgAA-gQAIPMCAAD6BAAg9AIAAPoEACD1AgAA-gQAIPYCAAD6BAAgAwAAAK4DACABAACvAwAwAgAAqwMAIAMAAACuAwAgAQAArwMAMAIAAKsDACADAAAArgMAIAEAAK8DADACAACrAwAgDdcCQAAAAAHkAgEAAAAB6wIBAAAAAe0CAQAAAAHuAgIAAAAB7wIBAAAAAfACAQAAAAHxAgEAAAAB8gIBAAAAAfMCAQAAAAH0AoAAAAAB9QKAAAAAAfYCAQAAAAEBHgAAswMAIA3XAkAAAAAB5AIBAAAAAesCAQAAAAHtAgEAAAAB7gICAAAAAe8CAQAAAAHwAgEAAAAB8QIBAAAAAfICAQAAAAHzAgEAAAAB9AKAAAAAAfUCgAAAAAH2AgEAAAABAR4AALUDADABHgAAtQMAMA3XAkAA9AQAIeQCAQD1BAAh6wIBAIAFACHtAgEAgAUAIe4CAgCsBQAh7wIBAIAFACHwAgEA9QQAIfECAQD1BAAh8gIBAIAFACHzAgEAgAUAIfQCgAAAAAH1AoAAAAAB9gIBAIAFACECAAAAqwMAIB4AALgDACAN1wJAAPQEACHkAgEA9QQAIesCAQCABQAh7QIBAIAFACHuAgIArAUAIe8CAQCABQAh8AIBAPUEACHxAgEA9QQAIfICAQCABQAh8wIBAIAFACH0AoAAAAAB9QKAAAAAAfYCAQCABQAhAgAAAK4DACAeAAC6AwAgAgAAAK4DACAeAAC6AwAgAwAAAKsDACAlAACzAwAgJgAAuAMAIAEAAACrAwAgAQAAAK4DACAOBAAApwUAICsAAKgFACAsAACrBQAgLQAAqgUAIC4AAKkFACDrAgAA-gQAIO0CAAD6BAAg7gIAAPoEACDvAgAA-gQAIPICAAD6BAAg8wIAAPoEACD0AgAA-gQAIPUCAAD6BAAg9gIAAPoEACAQ0QIAAJ4EADDSAgAAwQMAENMCAACeBAAw1wJAAI0EACHkAgEAiwQAIesCAQCWBAAh7QIBAJYEACHuAgIAnwQAIe8CAQCWBAAh8AIBAIsEACHxAgEAiwQAIfICAQCWBAAh8wIBAJYEACH0AgAAoAQAIPUCAACgBAAg9gIBAJYEACEDAAAArgMAIAEAAMADADAqAADBAwAgAwAAAK4DACABAACvAwAwAgAAqwMAIAEAAABAACABAAAAQAAgAwAAACAAIAEAAD8AMAIAAEAAIAMAAAAgACABAAA_ADACAABAACADAAAAIAAgAQAAPwAwAgAAQAAgCQYAAKUFACAMAACkBQAgFgAApgUAINcCQAAAAAHYAkAAAAAB5AIBAAAAAeoCAQAAAAHrAgEAAAAB7AIBAAAAAQEeAADJAwAgBtcCQAAAAAHYAkAAAAAB5AIBAAAAAeoCAQAAAAHrAgEAAAAB7AIBAAAAAQEeAADLAwAwAR4AAMsDADABAAAAGwAgAQAAAAUAIAkGAACWBQAgDAAAlQUAIBYAAJcFACDXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHqAgEAgAUAIesCAQCABQAh7AIBAIAFACECAAAAQAAgHgAA0AMAIAbXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHqAgEAgAUAIesCAQCABQAh7AIBAIAFACECAAAAIAAgHgAA0gMAIAIAAAAgACAeAADSAwAgAQAAABsAIAEAAAAFACADAAAAQAAgJQAAyQMAICYAANADACABAAAAQAAgAQAAACAAIAYEAACSBQAgLQAAlAUAIC4AAJMFACDqAgAA-gQAIOsCAAD6BAAg7AIAAPoEACAJ0QIAAJ0EADDSAgAA2wMAENMCAACdBAAw1wJAAI0EACHYAkAAjQQAIeQCAQCLBAAh6gIBAJYEACHrAgEAlgQAIewCAQCWBAAhAwAAACAAIAEAANoDADAqAADbAwAgAwAAACAAIAEAAD8AMAIAAEAAIAEAAAAmACABAAAAJgAgAwAAACQAIAEAACUAMAIAACYAIAMAAAAkACABAAAlADACAAAmACADAAAAJAAgAQAAJQAwAgAAJgAgCg0AAJAFACAPAACRBQAg1wJAAAAAAdgCQAAAAAHkAgEAAAAB5QIBAAAAAeYCAQAAAAHnAgIAAAAB6AICAAAAAekCIAAAAAEBHgAA4wMAIAjXAkAAAAAB2AJAAAAAAeQCAQAAAAHlAgEAAAAB5gIBAAAAAecCAgAAAAHoAgIAAAAB6QIgAAAAAQEeAADlAwAwAR4AAOUDADAKDQAAggUAIA8AAIMFACDXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHlAgEA9QQAIeYCAQCABQAh5wICAPMEACHoAgIA8wQAIekCIACBBQAhAgAAACYAIB4AAOgDACAI1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAh5QIBAPUEACHmAgEAgAUAIecCAgDzBAAh6AICAPMEACHpAiAAgQUAIQIAAAAkACAeAADqAwAgAgAAACQAIB4AAOoDACADAAAAJgAgJQAA4wMAICYAAOgDACABAAAAJgAgAQAAACQAIAYEAAD7BAAgKwAA_AQAICwAAP8EACAtAAD-BAAgLgAA_QQAIOYCAAD6BAAgC9ECAACVBAAw0gIAAPEDABDTAgAAlQQAMNcCQACNBAAh2AJAAI0EACHkAgEAiwQAIeUCAQCLBAAh5gIBAJYEACHnAgIAjAQAIegCAgCMBAAh6QIgAJcEACEDAAAAJAAgAQAA8AMAMCoAAPEDACADAAAAJAAgAQAAJQAwAgAAJgAgAQAAACoAIAEAAAAqACADAAAAKAAgAQAAKQAwAgAAKgAgAwAAACgAIAEAACkAMAIAACoAIAMAAAAoACABAAApADACAAAqACAHDgAA-AQAIBMAAPkEACDUAgEAAAAB1QIBAAAAAdYCAgAAAAHXAkAAAAAB2AJAAAAAAQEeAAD5AwAgBdQCAQAAAAHVAgEAAAAB1gICAAAAAdcCQAAAAAHYAkAAAAABAR4AAPsDADABHgAA-wMAMAcOAAD2BAAgEwAA9wQAINQCAQD1BAAh1QIBAPUEACHWAgIA8wQAIdcCQAD0BAAh2AJAAPQEACECAAAAKgAgHgAA_gMAIAXUAgEA9QQAIdUCAQD1BAAh1gICAPMEACHXAkAA9AQAIdgCQAD0BAAhAgAAACgAIB4AAIAEACACAAAAKAAgHgAAgAQAIAMAAAAqACAlAAD5AwAgJgAA_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-QIBAAAAAfoCgAAAAAH7AoAAAAAB_AKAAAAAAQ0EAACbBAAgKwAAowQAICwAAJsEACAtAACbBAAgLgAAmwQAINkCAgAAAAHaAgIAAAAF2wICAAAABdwCAgAAAAHdAgIAAAAB3gICAAAAAd8CAgAAAAHgAgIAogQAIQjZAggAAAAB2gIIAAAABdsCCAAAAAXcAggAAAAB3QIIAAAAAd4CCAAAAAHfAggAAAAB4AIIAKMEACEQ0QIAAKQEADDSAgAArgMAENMCAACkBAAw1wJAAKYEACHkAgEApQQAIesCAQCnBAAh7QIBAKcEACHuAgIAqAQAIe8CAQCnBAAh8AIBAKUEACHxAgEApQQAIfICAQCnBAAh8wIBAKcEACH0AgAAqQQAIPUCAACpBAAg9gIBAKcEACEL2QIBAAAAAdoCAQAAAATbAgEAAAAE3AIBAAAAAd0CAQAAAAHeAgEAAAAB3wIBAAAAAeACAQCUBAAh4QIBAAAAAeICAQAAAAHjAgEAAAABCNkCQAAAAAHaAkAAAAAE2wJAAAAABNwCQAAAAAHdAkAAAAAB3gJAAAAAAd8CQAAAAAHgAkAAkAQAIQvZAgEAAAAB2gIBAAAABdsCAQAAAAXcAgEAAAAB3QIBAAAAAd4CAQAAAAHfAgEAAAAB4AIBAJwEACHhAgEAAAAB4gIBAAAAAeMCAQAAAAEI2QICAAAAAdoCAgAAAAXbAgIAAAAF3AICAAAAAd0CAgAAAAHeAgIAAAAB3wICAAAAAeACAgCbBAAhDNkCgAAAAAHcAoAAAAAB3QKAAAAAAd4CgAAAAAHfAoAAAAAB4AKAAAAAAfcCAQAAAAH4AgEAAAAB-QIBAAAAAfoCgAAAAAH7AoAAAAAB_AKAAAAAAQXRAgAAqgQAMNICAACoAwAQ0wIAAKoEADD9AgIAjAQAIf4CAACrBAAgDwQAAI8EACAtAACsBAAgLgAArAQAINkCgAAAAAHcAoAAAAAB3QKAAAAAAd4CgAAAAAHfAoAAAAAB4AKAAAAAAfcCAQAAAAH4AgEAAAAB-QIBAAAAAfoCgAAAAAH7AoAAAAAB_AKAAAAAAQzZAoAAAAAB3AKAAAAAAd0CgAAAAAHeAoAAAAAB3wKAAAAAAeACgAAAAAH3AgEAAAAB-AIBAAAAAfkCAQAAAAH6AoAAAAAB-wKAAAAAAfwCgAAAAAEF0QIAAK0EADDSAgAAlQMAENMCAACtBAAw_QICAK4EACH-AgAArwQAIAjZAgIAAAAB2gICAAAABNsCAgAAAATcAgIAAAAB3QICAAAAAd4CAgAAAAHfAgIAAAAB4AICAI8EACEM2QKAAAAAAdwCgAAAAAHdAoAAAAAB3gKAAAAAAd8CgAAAAAHgAoAAAAAB9wIBAAAAAfgCAQAAAAH5AgEAAAAB-gKAAAAAAfsCgAAAAAH8AoAAAAABCdECAACwBAAw0gIAAI8DABDTAgAAsAQAMNcCQACNBAAh5AIBAIsEACH_AgEAiwQAIYADAQCLBAAhgQNAALEEACGCA0AAsQQAIQsEAACbBAAgLQAAswQAIC4AALMEACDZAkAAAAAB2gJAAAAABdsCQAAAAAXcAkAAAAAB3QJAAAAAAd4CQAAAAAHfAkAAAAAB4AJAALIEACELBAAAmwQAIC0AALMEACAuAACzBAAg2QJAAAAAAdoCQAAAAAXbAkAAAAAF3AJAAAAAAd0CQAAAAAHeAkAAAAAB3wJAAAAAAeACQACyBAAhCNkCQAAAAAHaAkAAAAAF2wJAAAAABdwCQAAAAAHdAkAAAAAB3gJAAAAAAd8CQAAAAAHgAkAAswQAIQnRAgAAtAQAMNICAAD8AgAQ0wIAALQEADDXAkAApgQAIeQCAQClBAAh_wIBAKUEACGAAwEApQQAIYEDQAC1BAAhggNAALUEACEI2QJAAAAAAdoCQAAAAAXbAkAAAAAF3AJAAAAAAd0CQAAAAAHeAkAAAAAB3wJAAAAAAeACQACzBAAhDNECAAC2BAAw0gIAAPYCABDTAgAAtgQAMNcCQACNBAAh5AIBAIsEACHrAgEAlgQAIe0CAQCWBAAhgwMBAIsEACGEAyAAlwQAIYUDQACxBAAhhgMBAJYEACGHAwEAlgQAIQbRAgAAtwQAMNICAADeAgAQ0wIAALcEADDWAgIAjAQAIeQCAgCMBAAhiAMBAIsEACEHDwAAuQQAINECAAC4BAAw0gIAAMsCABDTAgAAuAQAMNYCAgCuBAAh5AICAK4EACGIAwEApQQAIQOJAwAALAAgigMAACwAIIsDAAAsACAG0QIAALoEADDSAgAAxQIAENMCAAC6BAAw5AICAIwEACGIAwEAiwQAIYwDAgCMBAAhBw8AALkEACDRAgAAuwQAMNICAACyAgAQ0wIAALsEADDkAgIArgQAIYgDAQClBAAhjAMCAK4EACEH0QIAALwEADDSAgAArAIAENMCAAC8BAAw1QIBAIsEACHXAkAAjQQAIdgCQACNBAAh6wIBAIsEACEG0QIAAL0EADDSAgAAlgIAENMCAAC9BAAw1gICAIwEACHkAgIAjAQAIYgDAQCLBAAhBw8AALkEACDRAgAAvgQAMNICAACDAgAQ0wIAAL4EADDWAgIArgQAIeQCAgCuBAAhiAMBAKUEACEJ0QIAAL8EADDSAgAA_QEAENMCAAC_BAAw5AIBAIsEACH9AgIAjAQAIYgDAQCLBAAhjQMBAIsEACGOAwIAjAQAIY8DAgCMBAAhCdECAADABAAw0gIAAOcBABDTAgAAwAQAMNcCQACNBAAh2AJAAI0EACHkAgEAiwQAIYEDQACNBAAhjAMBAIsEACGQAwEAiwQAIQnRAgAAwQQAMNICAADUAQAQ0wIAAMEEADDXAkAApgQAIdgCQACmBAAh5AIBAKUEACGBA0AApgQAIYwDAQClBAAhkAMBAKUEACEF0QIAAMIEADDSAgAAzgEAENMCAADCBAAw5AICAIwEACGIAwEAiwQAIQYDAADEBAAg0QIAAMMEADDSAgAAAwAQ0wIAAMMEADDkAgIArgQAIYgDAQClBAAhA4kDAAAFACCKAwAABQAgiwMAAAUAIBDRAgAAxQQAMNICAAC2AQAQ0wIAAMUEADDXAkAAjQQAIdgCQACNBAAh5AIBAIsEACHrAgEAiwQAIZEDAQCLBAAhkgMBAIsEACGTAwEAlgQAIZQDAQCWBAAhlQMBAJYEACGWA0AAsQQAIZcDQACxBAAhmAMBAJYEACGZAwEAlgQAIQvRAgAAxgQAMNICAACgAQAQ0wIAAMYEADDXAkAAjQQAIdgCQACNBAAh5AIBAIsEACHrAgEAiwQAIYEDQACNBAAhmgMBAIsEACGbAwEAlgQAIZwDAQCWBAAhDtECAADHBAAw0gIAAIoBABDTAgAAxwQAMOQCAQCLBAAh7AIBAIsEACGdAwEAiwQAIZ4DAQCLBAAhnwMBAIsEACGgAwEAiwQAIaEDAQCLBAAhogMBAIsEACGjAyAAlwQAIaQDIACXBAAhpQMgAJcEACEK0QIAAMgEADDSAgAAdAAQ0wIAAMgEADDkAgEAiwQAIesCAQCWBAAh7QIBAJYEACGmAwEAiwQAIacDAQCLBAAhqAMBAIsEACGpAwEAlgQAIQvRAgAAyQQAMNICAABcABDTAgAAyQQAMNcCQACNBAAh2AJAAI0EACHkAgEAiwQAIe0CAQCLBAAh7gICAJ8EACGIAwEAiwQAIaoDIACXBAAhqwMBAJYEACEMBgAAzAQAIAwAAMsEACAWAADNBAAg0QIAAMoEADDSAgAAIAAQ0wIAAMoEADDXAkAApgQAIdgCQACmBAAh5AIBAKUEACHqAgEApwQAIesCAQCnBAAh7AIBAKcEACEQBgAAzAQAIA0AAOAEACDRAgAA3wQAMNICAAAbABDTAgAA3wQAMNcCQACmBAAh5AIBAKUEACHrAgEApwQAIe0CAQCnBAAhgwMBAKUEACGEAyAA3AQAIYUDQAC1BAAhhgMBAKcEACGHAwEApwQAIa8DAAAbACCwAwAAGwAgFAUAAOgEACAHAADpBAAgCAAA6gQAIAsAAOsEACAUAADWBAAgFwAA7AQAIBgAAO0EACDRAgAA5wQAMNICAAAFABDTAgAA5wQAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIe0CAQClBAAh7gICAKgEACGIAwEApQQAIaoDIADcBAAhqwMBAKcEACGvAwAABQAgsAMAAAUAIAOJAwAAJAAgigMAACQAIIsDAAAkACAC1QIBAAAAAesCAQAAAAEJBgAA0AQAIBMAANEEACDRAgAAzwQAMNICAAA1ABDTAgAAzwQAMNUCAQClBAAh1wJAAKYEACHYAkAApgQAIesCAQClBAAhFAUAAOgEACAHAADpBAAgCAAA6gQAIAsAAOsEACAUAADWBAAgFwAA7AQAIBgAAO0EACDRAgAA5wQAMNICAAAFABDTAgAA5wQAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIe0CAQClBAAh7gICAKgEACGIAwEApQQAIaoDIADcBAAhqwMBAKcEACGvAwAABQAgsAMAAAUAIBAQAADTBAAgEQAA1AQAIBIAANUEACAUAADWBAAgFQAA1wQAINECAADSBAAw0gIAACwAENMCAADSBAAw5AIBAKUEACH9AgIArgQAIYgDAQClBAAhjQMBAKUEACGOAwIArgQAIY8DAgCuBAAhrwMAACwAILADAAAsACAOEAAA0wQAIBEAANQEACASAADVBAAgFAAA1gQAIBUAANcEACDRAgAA0gQAMNICAAAsABDTAgAA0gQAMOQCAQClBAAh_QICAK4EACGIAwEApQQAIY0DAQClBAAhjgMCAK4EACGPAwIArgQAIQkPAAC5BAAg0QIAALsEADDSAgAAsgIAENMCAAC7BAAw5AICAK4EACGIAwEApQQAIYwDAgCuBAAhrwMAALICACCwAwAAsgIAIAkPAAC5BAAg0QIAALgEADDSAgAAywIAENMCAAC4BAAw1gICAK4EACHkAgIArgQAIYgDAQClBAAhrwMAAMsCACCwAwAAywIAIAkPAAC5BAAg0QIAAL4EADDSAgAAgwIAENMCAAC-BAAw1gICAK4EACHkAgIArgQAIYgDAQClBAAhrwMAAIMCACCwAwAAgwIAIAOJAwAANQAgigMAADUAIIsDAAA1ACADiQMAACgAIIoDAAAoACCLAwAAKAAgAtQCAQAAAAHVAgEAAAABCg4AANoEACATAADRBAAg0QIAANkEADDSAgAAKAAQ0wIAANkEADDUAgEApQQAIdUCAQClBAAh1gICAK4EACHXAkAApgQAIdgCQACmBAAhDw0AAN0EACAPAADXBAAg0QIAANsEADDSAgAAJAAQ0wIAANsEADDXAkAApgQAIdgCQACmBAAh5AIBAKUEACHlAgEApQQAIeYCAQCnBAAh5wICAK4EACHoAgIArgQAIekCIADcBAAhrwMAACQAILADAAAkACANDQAA3QQAIA8AANcEACDRAgAA2wQAMNICAAAkABDTAgAA2wQAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIeUCAQClBAAh5gIBAKcEACHnAgIArgQAIegCAgCuBAAh6QIgANwEACEC2QIgAAAAAeACIACZBAAhDgYAAMwEACAMAADLBAAgFgAAzQQAINECAADKBAAw0gIAACAAENMCAADKBAAw1wJAAKYEACHYAkAApgQAIeQCAQClBAAh6gIBAKcEACHrAgEApwQAIewCAQCnBAAhrwMAACAAILADAAAgACAC6wIBAAAAAYcDAQAAAAEOBgAAzAQAIA0AAOAEACDRAgAA3wQAMNICAAAbABDTAgAA3wQAMNcCQACmBAAh5AIBAKUEACHrAgEApwQAIe0CAQCnBAAhgwMBAKUEACGEAyAA3AQAIYUDQAC1BAAhhgMBAKcEACGHAwEApwQAIQ4GAADMBAAgDAAAywQAIBYAAM0EACDRAgAAygQAMNICAAAgABDTAgAAygQAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIeoCAQCnBAAh6wIBAKcEACHsAgEApwQAIa8DAAAgACCwAwAAIAAgDwkAAOIEACDRAgAA4QQAMNICAAAWABDTAgAA4QQAMOQCAQClBAAh7AIBAKUEACGdAwEApQQAIZ4DAQClBAAhnwMBAKUEACGgAwEApQQAIaEDAQClBAAhogMBAKUEACGjAyAA3AQAIaQDIADcBAAhpQMgANwEACEOBgAAzAQAIAoAAOQEACDRAgAA4wQAMNICAAAR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_BQAgCQAAACAAIAYAAJYFACAWAACXBQAgHgAAvwUAINcCQAD0BAAh2AJAAPQEACHkAgEA9QQAIesCAQCABQAh7AIBAIAFACEHBgAAlgUAIBYAAJcFACDXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHrAgEAgAUAIewCAQCABQAhAyUAAIQIACCxAwAAhQgAILcDAAABACADJQAAuwUAILEDAAC8BQAgtwMAAEAAIAAAAAAACyUAAMgFADAmAADNBQAwsQMAAMkFADCyAwAAygUAMLMDAADLBQAgtAMAAMwFADC1AwAAzAUAMLYDAADMBQAwtwMAAMwFADC4AwAAzgUAMLkDAADPBQAwCRAAAO8FACASAADwBQAgFAAA8QUAIBUAAPIFACDkAgEAAAAB_QICAAAAAYgDAQAAAAGNAwEAAAABjwMCAAAAAQIAAAAuACAlAADuBQAgAwAAAC4AICUAAO4FACAmAADSBQAgAR4AAIMIADAOEAAA0wQAIBEAANQEACASAADVBAAgFAAA1gQAIBUAANcEACDRAgAA0gQAMNICAAAsABDTAgAA0gQAMOQCAQAAAAH9AgIArgQAIYgDAQAAAAGNAwEApQQAIY4DAgCuBAAhjwMCAK4EACECAAAALgAgHgAA0gUAIAIAAADQBQAgHgAA0QUAIAnRAgAAzwUAMNICAADQBQAQ0wIAAM8FADDkAgEApQQAIf0CAgCuBAAhiAMBAKUEACGNAwEApQQAIY4DAgCuBAAhjwMCAK4EACEJ0QIAAM8FADDSAgAA0AUAENMCAADPBQAw5AIBAKUEACH9AgIArgQAIYgDAQClBAAhjQMBAKUEACGOAwIArgQAIY8DAgCuBAAhBeQCAQD1BAAh_QICAPMEACGIAwEA9QQAIY0DAQD1BAAhjwMCAPMEACEJEAAA0wUAIBIAANQFACAUAADVBQAgFQAA1gUAIOQCAQD1BAAh_QICAPMEACGIAwEA9QQAIY0DAQD1BAAhjwMCAPMEACEFJQAA9AcAICYAAIEIACCxAwAA9QcAILIDAACACAAgtwMAAK8CACAFJQAA8gcAICYAAP4HACCxAwAA8wcAILIDAAD9BwAgtwMAAIACACALJQAA4AUAMCYAAOUFADCxAwAA4QUAMLIDAADiBQAwswMAAOMFACC0AwAA5AUAMLUDAADkBQAwtgMAAOQFADC3AwAA5AUAMLgDAADmBQAwuQMAAOcFADALJQAA1wUAMCYAANsFADCxAwAA2AUAMLIDAADZBQAwswMAANoFACC0AwAAiAUAMLUDAACIBQAwtgMAAIgFADC3AwAAiAUAMLgDAADcBQAwuQMAAIsFADAFDgAA-AQAINQCAQAAAAHWAgIAAAAB1wJAAAAAAdgCQAAAAAECAAAAKgAgJQAA3wUAIAMAAAAqACAlAADfBQAgJgAA3gUAIAEeAAD8BwAwAgAAACoAIB4AAN4FACACAAAAjAUAIB4AAN0FACAE1AIBAPUEACHWAgIA8wQAIdcCQAD0BAAh2AJAAPQEACEFDgAA9gQAINQCAQD1BAAh1gICAPMEACHXAkAA9AQAIdgCQAD0BAAhBQ4AAPgEACDUAgEAAAAB1gICAAAAAdcCQAAAAAHYAkAAAAABBAYAAO0FACDXAkAAAAAB2AJAAAAAAesCAQAAAAECAAAANwAgJQAA7AUAIAMAAAA3ACAlAADsBQAgJgAA6gUAIAEeAAD7BwAwCgYAANAEACATAADRBAAg0QIAAM8EADDSAgAANQAQ0wIAAM8EADDVAgEApQQAIdcCQACmBAAh2AJAAKYEACHrAgEApQQAIawDAADOBAAgAgAAADcAIB4AAOoFACACAAAA6AUAIB4AAOkFACAH0QIAAOcFADDSAgAA6AUAENMCAADnBQAw1QIBAKUEACHXAkAApgQAIdgCQACmBAAh6wIBAKUEACEH0QIAAOcFADDSAgAA6AUAENMCAADnBQAw1QIBAKUEACHXAkAApgQAIdgCQACmBAAh6wIBAKUEACED1wJAAPQEACHYAkAA9AQAIesCAQD1BAAhBAYAAOsFACDXAkAA9AQAIdgCQAD0BAAh6wIBAPUEACEFJQAA9gcAICYAAPkHACCxAwAA9wcAILIDAAD4BwAgtwMAAAEAIAQGAADtBQAg1wJAAAAAAdgCQAAAAAHrAgEAAAABAyUAAPYHACCxAwAA9wcAILcDAAABACAJEAAA7wUAIBIAAPAFACAUAADxBQAgFQAA8gUAIOQCAQAAAAH9AgIAAAABiAMBAAAAAY0DAQAAAAGPAwIAAAABAyUAAPQHACCxAwAA9QcAILcDAACvAgAgAyUAAPIHACCxAwAA8wcAILcDAACAAgAgBCUAAOAFADCxAwAA4QUAMLMDAADjBQAgtwMAAOQFADAEJQAA1wUAMLEDAADYBQAwswMAANoFACC3AwAAiAUAMAQlAADIBQAwsQMAAMkFADCzAwAAywUAILcDAADMBQAwAAAAAAAACyUAAPsFADAmAAD_BQAwsQMAAPwFADCyAwAA_QUAMLMDAAD-BQAgtAMAAMwFADC1AwAAzAUAMLYDAADMBQAwtwMAAMwFADC4AwAAgAYAMLkDAADPBQAwCREAAIUGACASAADwBQAgFAAA8QUAIBUAAPIFACDkAgEAAAABiAMBAAAAAY0DAQAAAAGOAwIAAAABjwMCAAAAAQIAAAAuACAlAACEBgAgAwAAAC4AICUAAIQGACAmAACCBgAgAR4AAPEHADACAAAALgAgHgAAggYAIAIAAADQBQAgHgAAgQYAIAXkAgEA9QQAIYgDAQD1BAAhjQMBAPUEACGOAwIA8wQAIY8DAgDzBAAhCREAAIMGACASAADUBQAgFAAA1QUAIBUAANYFACDkAgEA9QQAIYgDAQD1BAAhjQMBAPUEACGOAwIA8wQAIY8DAgDzBAAhBSUAAOwHACAmAADvBwAgsQMAAO0HACCyAwAA7gcAILcDAADIAgAgCREAAIUGACASAADwBQAgFAAA8QUAIBUAAPIFACDkAgEAAAABiAMBAAAAAY0DAQAAAAGOAwIAAAABjwMCAAAAAQMlAADsBwAgsQMAAO0HACC3AwAAyAIAIAQlAAD7BQAwsQMAAPwFADCzAwAA_gUAILcDAADMBQAwAAAABSUAAOcHACAmAADqBwAgsQMAAOgHACCyAwAA6QcAILcDAAAuACADJQAA5wcAILEDAADoBwAgtwMAAC4AIAAAAAAACyUAAJIGADAmAACWBgAwsQMAAJMGADCyAwAAlAYAMLMDAACVBgAgtAMAAMwFADC1AwAAzAUAMLYDAADMBQAwtwMAAMwFADC4AwAAlwYAMLkDAADPBQAwCRAAAO8FACARAACFBgAgFAAA8QUAIBUAAPIFACDkAgEAAAAB_QICAAAAAYgDAQAAAAGNAwEAAAABjgMCAAAAAQIAAAAuACAlAACaBgAgAwAAAC4AICUAAJoGACAmAACZBgAgAR4AAOYHADACAAAALgAgHgAAmQYAIAIAAADQBQAgHgAAmAYAIAXkAgEA9QQAIf0CAgDzBAAhiAMBAPUEACGNAwEA9QQAIY4DAgDzBAAhCRAAANMFACARAACDBgAgFAAA1QUAIBUAANYFACDkAgEA9QQAIf0CAgDzBAAhiAMBAPUEACGNAwEA9QQAIY4DAgDzBAAhCRAAAO8FACARAACFBgAgFAAA8QUAIBUAAPIFACDkAgEAAAAB_QICAAAAAYgDAQAAAAGNAwEAAAABjgMCAAAAAQQlAACSBgAwsQMAAJMGADCzAwAAlQYAILcDAADMBQAwAAAAAAAAAAAAAAAAAAslAACqBgAwJgAArwYAMLEDAACrBgAwsgMAAKwGADCzAwAArQYAILQDAACuBgAwtQMAAK4GADC2AwAArgYAMLcDAACuBgAwuAMAALAGADC5AwAAsQYAMA0HAACPBwAgCAAAkAcAIAsAAJEHACAUAACTBwAgFwAAkgcAIBgAAJQHACDXAkAAAAAB2AJAAAAAAeQCAQAAAAHtAgEAAAABiAMBAAAAAaoDIAAAAAGrAwEAAAABAgAAAAEAICUAAI4HACADAAAAAQAgJQAAjgcAICYAALQGACABHgAA5QcAMBIFAADoBAAgBwAA6QQAIAgAAOoEACALAADrBAAgFAAA1gQAIBcAAOwEACAYAADtBAAg0QIAAOcEADDSAgAABQAQ0wIAAOcEADDXAkAApgQAIdgCQACmBAAh5AIBAAAAAe0CAQAAAAHuAgIAqAQAIYgDAQClBAAhqgMgANwEACGrAwEApwQAIQIAAAABACAeAAC0BgAgAgAAALIGACAeAACzBgAgC9ECAACxBgAw0gIAALIGABDTAgAAsQYAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIe0CAQClBAAh7gICAKgEACGIAwEApQQAIaoDIADcBAAhqwMBAKcEACEL0QIAALEGADDSAgAAsgYAENMCAACxBgAw1wJAAKYEACHYAkAApgQAIeQCAQClBAAh7QIBAKUEACHuAgIAqAQAIYgDAQClBAAhqgMgANwEACGrAwEApwQAIQfXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHtAgEA9QQAIYgDAQD1BAAhqgMgAIEFACGrAwEAgAUAIQ0HAAC1BgAgCAAAtgYAIAsAALcGACAUAAC5BgAgFwAAuAYAIBgAALoGACDXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHtAgEA9QQAIYgDAQD1BAAhqgMgAIEFACGrAwEAgAUAIQslAACCBwAwJgAAhwcAMLEDAACDBwAwsgMAAIQHADCzAwAAhQcAILQDAACGBwAwtQMAAIYHADC2AwAAhgcAMLcDAACGBwAwuAMAAIgHADC5AwAAiQcAMAslAAD2BgAwJgAA-wYAMLEDAAD3BgAwsgMAAPgGADCzAwAA-QYAILQDAAD6BgAwtQMAAPoGADC2AwAA-gYAMLcDAAD6BgAwuAMAAPwGADC5AwAA_QYAMAslAADcBgAwJgAA4QYAMLEDAADdBgAwsgMAAN4GADCzAwAA3wYAILQDAADgBgAwtQMAAOAGADC2AwAA4AYAMLcDAADgBgAwuAMAAOIGADC5AwAA4wYAMAslAADQBgAwJgAA1QYAMLEDAADRBgAwsgMAANIGADCzAwAA0wYAILQDAADUBgAwtQMAANQGADC2AwAA1AYAMLcDAADUBgAwuAMAANYGADC5AwAA1wYAMAslAADHBgAwJgAAywYAMLEDAADIBgAwsgMAAMkGADCzAwAAygYAILQDAADkBQAwtQMAAOQFADC2AwAA5AUAMLcDAADkBQAwuAMAAMwGADC5AwAA5wUAMAslAAC7BgAwJgAAwAYAMLEDAAC8BgAwsgMAAL0GADCzAwAAvgYAILQDAAC_BgAwtQMAAL8GADC2AwAAvwYAMLcDAAC_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_gYAIB4AAP8GACAQ0QIAAP0GADDSAgAA_gYAENMCAAD9BgAw1wJAAKYEACHYAkAApgQAIeQCAQClBAAh6wIBAKUEACGRAwEApQQAIZIDAQClBAAhkwMBAKcEACGUAwEApwQAIZUDAQCnBAAhlgNAALUEACGXA0AAtQQAIZgDAQCnBAAhmQMBAKcEACEQ0QIAAP0GADDSAgAA_gYAENMCAAD9BgAw1wJAAKYEACHYAkAApgQAIeQCAQClBAAh6wIBAKUEACGRAwEApQQAIZIDAQClBAAhkwMBAKcEACGUAwEApwQAIZUDAQCnBAAhlgNAALUEACGXA0AAtQQAIZgDAQCnBAAhmQMBAKcEACEM1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAhkQMBAPUEACGSAwEA9QQAIZMDAQCABQAhlAMBAIAFACGVAwEAgAUAIZYDQAC1BQAhlwNAALUFACGYAwEAgAUAIZkDAQCABQAhDNcCQAD0BAAh2AJAAPQEACHkAgEA9QQAIZEDAQD1BAAhkgMBAPUEACGTAwEAgAUAIZQDAQCABQAhlQMBAIAFACGWA0AAtQUAIZcDQAC1BQAhmAMBAIAFACGZAwEAgAUAIQzXAkAAAAAB2AJAAAAAAeQCAQAAAAGRAwEAAAABkgMBAAAAAZMDAQAAAAGUAwEAAAABlQMBAAAAAZYDQAAAAAGXA0AAAAABmAMBAAAAAZkDAQAAAAEH1wJAAAAAAdgCQAAAAAHkAgEAAAABgQNAAAAAAZoDAQAAAAGbAwEAAAABnAMBAAAAAQIAAAALACAlAACNBwAgAwAAAAsAICUAAI0HACAmAACMBwAgAR4AAN4HADAMBgAA0AQAINECAADmBAAw0gIAAAkAENMCAADmBAAw1wJAAKYEACHYAkAApgQAIeQCAQAAAAHrAgEApQQAIYEDQACmBAAhmgMBAAAAAZsDAQCnBAAhnAMBAKcEACECAAAACwAgHgAAjAcAIAIAAACKBwAgHgAAiwcAIAvRAgAAiQcAMNICAACKBwAQ0wIAAIkHADDXAkAApgQAIdgCQACmBAAh5AIBAKUEACHrAgEApQQAIYEDQACmBAAhmgMBAKUEACGbAwEApwQAIZwDAQCnBAAhC9ECAACJBwAw0gIAAIoHABDTAgAAiQcAMNcCQACmBAAh2AJAAKYEACHkAgEApQQAIesCAQClBAAhgQNAAKYEACGaAwEApQQAIZsDAQCnBAAhnAMBAKcEACEH1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAhgQNAAPQEACGaAwEA9QQAIZsDAQCABQAhnAMBAIAFACEH1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAhgQNAAPQEACGaAwEA9QQAIZsDAQCABQAhnAMBAIAFACEH1wJAAAAAAdgCQAAAAAHkAgEAAAABgQNAAAAAAZoDAQAAAAGbAwEAAAABnAMBAAAAAQ0HAACPBwAgCAAAkAcAIAsAAJEHACAUAACTBwAgFwAAkgcAIBgAAJQHACDXAkAAAAAB2AJAAAAAAeQCAQAAAAHtAgEAAAABiAMBAAAAAaoDIAAAAAGrAwEAAAABBCUAAIIHADCxAwAAgwcAMLMDAACFBwAgtwMAAIYHADAEJQAA9gYAMLEDAAD3BgAwswMAAPkGACC3AwAA-gYAMAQlAADcBgAwsQMAAN0GADCzAwAA3wYAILcDAADgBgAwBCUAANAGADCxAwAA0QYAMLMDAADTBgAgtwMAANQGADAEJQAAxwYAMLEDAADIBgAwswMAAMoGACC3AwAA5AUAMAQlAAC7BgAwsQMAALwGADCzAwAAvgYAILcDAAC_BgAwBCUAAKoGADCxAwAAqwYAMLMDAACtBgAgtwMAAK4GADAAAAAABSUAANkHACAmAADcBwAgsQMAANoHACCyAwAA2wcAILcDAAABACADJQAA2QcAILEDAADaBwAgtwMAAAEAIAAAAAUlAADUBwAgJgAA1wcAILEDAADVBwAgsgMAANYHACC3AwAAAQAgAyUAANQHACCxAwAA1QcAILcDAAABACAAAAAFJQAAzwcAICYAANIHACCxAwAA0AcAILIDAADRBwAgtwMAABMAIAMlAADPBwAgsQMAANAHACC3AwAAEwAgAAAAByUAAMoHACAmAADNBwAgsQMAAMsHACCyAwAAzAcAILUDAAAFACC2AwAABQAgtwMAAAEAIAMlAADKBwAgsQMAAMsHACC3AwAAAQAgAAAAAAAHJQAAxQcAICYAAMgHACCxAwAAxgcAILIDAADHBwAgtQMAAAMAILYDAAADACC3AwAAuQEAIAMlAADFBwAgsQMAAMYHACC3AwAAuQEAIAcGAACzBwAgDQAAvAcAIOsCAAD6BAAg7QIAAPoEACCFAwAA-gQAIIYDAAD6BAAghwMAAPoEACAJBQAAvwcAIAcAAMAHACAIAADBBwAgCwAAwgcAIBQAALkHACAXAADDBwAgGAAAxAcAIO4CAAD6BAAgqwMAAPoEACAABRAAALYHACARAAC3BwAgEgAAuAcAIBQAALkHACAVAAC6BwAgAQ8AAPQFACABDwAA9AUAIAEPAAD0BQAgAAADDQAAvAcAIA8AALoHACDmAgAA-gQAIAYGAACzBwAgDAAAsgcAIBYAALQHACDqAgAA-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_BwAgBQAAAIMCACAeAAD_BwAg1gICAPMEACHkAgIA8wQAIYgDAQD1BAAhA9YCAgDzBAAh5AICAPMEACGIAwEA9QQAIQMAAACyAgAgJQAA9AcAICYAAIIIACAFAAAAsgIAIB4AAIIIACDkAgIA8wQAIYgDAQD1BAAhjAMCAPMEACED5AICAPMEACGIAwEA9QQAIYwDAgDzBAAhBeQCAQAAAAH9AgIAAAABiAMBAAAAAY0DAQAAAAGPAwIAAAABDgUAALEHACAHAACPBwAgCAAAkAcAIAsAAJEHACAUAACTBwAgGAAAlAcAINcCQAAAAAHYAkAAAAAB5AIBAAAAAe0CAQAAAAHuAgIAAAABiAMBAAAAAaoDIAAAAAGrAwEAAAABAgAAAAEAICUAAIQIACADAAAABQAgJQAAhAgAICYAAIgIACAQAAAABQAgBQAAsAcAIAcAALUGACAIAAC2BgAgCwAAtwYAIBQAALkGACAYAAC6BgAgHgAAiAgAINcCQAD0BAAh2AJAAPQEACHkAgEA9QQAIe0CAQD1BAAh7gICAKwFACGIAwEA9QQAIaoDIACBBQAhqwMBAIAFACEOBQAAsAcAIAcAALUGACAIAAC2BgAgCwAAtwYAIBQAALkGACAYAAC6BgAg1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAh7QIBAPUEACHuAgIArAUAIYgDAQD1BAAhqgMgAIEFACGrAwEAgAUAIQ4FAACxBwAgBwAAjwcAIAgAAJAHACALAACRBwAgFAAAkwcAIBcAAJIHACDXAkAAAAAB2AJAAAAAAeQCAQAAAAHtAgEAAAAB7gICAAAAAYgDAQAAAAGqAyAAAAABqwMBAAAAAQIAAAABACAlAACJCAAgCgYAAMAFACDXAkAAAAAB5AIBAAAAAesCAQAAAAHtAgEAAAABgwMBAAAAAYQDIAAAAAGFA0AAAAABhgMBAAAAAYcDAQAAAAECAAAAHQAgJQAAiwgAIAfXAkAAAAAB2AJAAAAAAeQCAQAAAAHmAgEAAAAB5wICAAAAAegCAgAAAAHpAiAAAAABAwAAAAUAICUAAIkIACAmAACQCAAgEAAAAAUAIAUAALAHACAHAAC1BgAgCAAAtgYAIAsAALcGACAUAAC5BgAgFwAAuAYAIB4AAJAIACDXAkAA9AQAIdgCQAD0BAAh5AIBAPUEACHtAgEA9QQAIe4CAgCsBQAhiAMBAPUEACGqAyAAgQUAIasDAQCABQAhDgUAALAHACAHAAC1BgAgCAAAtgYAIAsAALcGACAUAAC5BgAgFwAAuAYAINcCQAD0BAAh2AJAAPQEACHkAgEA9QQAIe0CAQD1BAAh7gICAKwFACGIAwEA9QQAIaoDIACBBQAhqwMBAIAFACEDAAAAGwAgJQAAiwgAICYAAJMIACAMAAAAGwAgBgAAuQUAIB4AAJMIACDXAkAA9AQAIeQCAQD1BAAh6wIBAIAFACHtAgEAgAUAIYMDAQD1BAAhhAMgAIEFACGFA0AAtQUAIYYDAQCABQAhhwMBAIAFACEKBgAAuQUAINcCQAD0BAAh5AIBAPUEACHrAgEAgAUAIe0CAQCABQAhgwMBAPUEACGEAyAAgQUAIYUDQAC1BQAhhgMBAIAFACGHAwEAgAUAIQgGAAClBQAgDAAApAUAINcCQAAAAAHYAkAAAAAB5AIBAAAAAeoCAQAAAAHrAgEAAAAB7AIBAAAAAQIAAABAACAlAACUCAAgBNUCAQAAAAHWAgIAAAAB1wJAAAAAAdgCQAAAAAEDAAAAIAAgJQAAlAgAICYAAJkIACAKAAAAIAAgBgAAlgUAIAwAAJUFACAeAACZCAAg1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAh6gIBAIAFACHrAgEAgAUAIewCAQCABQAhCAYAAJYFACAMAACVBQAg1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAh6gIBAIAFACHrAgEAgAUAIewCAQCABQAhChAAAO8FACARAACFBgAgEgAA8AUAIBQAAPEFACDkAgEAAAAB_QICAAAAAYgDAQAAAAGNAwEAAAABjgMCAAAAAY8DAgAAAAECAAAALgAgJQAAmggAIAkNAACQBQAg1wJAAAAAAdgCQAAAAAHkAgEAAAAB5QIBAAAAAeYCAQAAAAHnAgIAAAAB6AICAAAAAekCIAAAAAECAAAAJgAgJQAAnAgAIAMAAAAsACAlAACaCAAgJgAAoAgAIAwAAAAsACAQAADTBQAgEQAAgwYAIBIAANQFACAUAADVBQAgHgAAoAgAIOQCAQD1BAAh_QICAPMEACGIAwEA9QQAIY0DAQD1BAAhjgMCAPMEACGPAwIA8wQAIQoQAADTBQAgEQAAgwYAIBIAANQFACAUAADVBQAg5AIBAPUEACH9AgIA8wQAIYgDAQD1BAAhjQMBAPUEACGOAwIA8wQAIY8DAgDzBAAhAwAAACQAICUAAJwIACAmAACjCAAgCwAAACQAIA0AAIIFACAeAACjCAAg1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAh5QIBAPUEACHmAgEAgAUAIecCAgDzBAAh6AICAPMEACHpAiAAgQUAIQkNAACCBQAg1wJAAPQEACHYAkAA9AQAIeQCAQD1BAAh5QIBAPUEACHmAgEAgAUAIecCAgDzBAAh6AICAPMEACHpAiAAgQUAIQgEABgFBAIHDAQIEAULFAYUPhQXHgkYQQoCAwcBBAADAQMIAAEGAAEBBgABAwQACAYVAQoZBwEJAAYBChoAAgYfAQ0hCgQEABcGIwEMIgkWJwsDBAAWDQAKDysMAg4ACxMADQYEABUQAA4RABASABIUOBQVOQwCBAAPDy8NAQ8wAAIEABEPMQ0BDzIAAgQAEw8zDQEPNAACBgABEwANAhQ6ABU7AAEPPAABFj0ABgdCAAhDAAtEABRGABdFABhHAAABBVECAQVXAgUEAB0rAB4sAB8tACAuACEAAAAAAAUEAB0rAB4sAB8tACAuACEBBmkBAQZvAQMEACYtACcuACgAAAADBAAmLQAnLgAoAQkABgEJAAYDBAAtLQAuLgAvAAAAAwQALS0ALi4ALwEGAAEBBgABAwQANC0ANS4ANgAAAAMEADQtADUuADYBBgABAQYAAQMEADstADwuAD0AAAADBAA7LQA8LgA9AAAFBABCKwBDLABELQBFLgBGAAAAAAAFBABCKwBDLABELQBFLgBGAAAAAwQATC0ATS4ATgAAAAMEAEwtAE0uAE4DEAAOEQAQEgASAxAADhEAEBIAEgUEAFMrAFQsAFUtAFYuAFcAAAAAAAUEAFMrAFQsAFUtAFYuAFcAAAUEAFwrAF0sAF4tAF8uAGAAAAAAAAUEAFwrAF0sAF4tAF8uAGACBgABEwANAgYAARMADQMEAGUtAGYuAGcAAAADBABlLQBmLgBnAAAFBABsKwBtLABuLQBvLgBwAAAAAAAFBABsKwBtLABuLQBvLgBwAAAFBAB1KwB2LAB3LQB4LgB5AAAAAAAFBAB1KwB2LAB3LQB4LgB5AQbrAgEBBvECAQMEAH4tAH8uAIABAAAAAwQAfi0Afy4AgAEAAAADBACGAS0AhwEuAIgBAAAAAwQAhgEtAIcBLgCIAQAAAAUEAI4BKwCPASwAkAEtAJEBLgCSAQAAAAAABQQAjgErAI8BLACQAS0AkQEuAJIBAAAABQQAmAErAJkBLACaAS0AmwEuAJwBAAAAAAAFBACYASsAmQEsAJoBLQCbAS4AnAECBs8DAQzOAwkCBtYDAQzVAwkDBAChAS0AogEuAKMBAAAAAwQAoQEtAKIBLgCjAQENAAoBDQAKBQQAqAErAKkBLACqAS0AqwEuAKwBAAAAAAAFBACoASsAqQEsAKoBLQCrAS4ArAECDgALEwANAg4ACxMADQUEALEBKwCyASwAswEtALQBLgC1AQAAAAAABQQAsQErALIBLACzAS0AtAEuALUBGQIBGkgBG0kBHEoBHUsBH00BIE8ZIVAaIlMBI1UZJFYbJ1gBKFkBKVoZL10cMF4iMV8GMmAGM2EGNGIGNWMGNmUGN2cZOGgjOWsGOm0ZO24kPHAGPXEGPnIZP3UlQHYpQXcHQngHQ3kHRHoHRXsHRn0HR38ZSIABKkmCAQdKhAEZS4UBK0yGAQdNhwEHTogBGU-LASxQjAEwUY0BBFKOAQRTjwEEVJABBFWRAQRWkwEEV5UBGViWATFZmAEEWpoBGVubATJcnAEEXZ0BBF6eARlfoQEzYKIBN2GjAQVipAEFY6UBBWSmAQVlpwEFZqkBBWerARlorAE4aa4BBWqwARlrsQE5bLIBBW2zAQVutAEZb7cBOnC4AT5xugECcrsBAnO9AQJ0vgECdb8BAnbBAQJ3wwEZeMQBP3nGAQJ6yAEZe8kBQHzKAQJ9ywECfswBGX_PAUGAAdABR4EB0gFIggHTAUiDAdYBSIQB1wFIhQHYAUiGAdoBSIcB3AEZiAHdAUmJAd8BSIoB4QEZiwHiAUqMAeMBSI0B5AFIjgHlARmPAegBS5AB6QFPkQHqAQ2SAesBDZMB7AENlAHtAQ2VAe4BDZYB8AENlwHyARmYAfMBUJkB9QENmgH3ARmbAfgBUZwB-QENnQH6AQ2eAfsBGZ8B_gFSoAH_AVihAYECEqIBggISowGFAhKkAYYCEqUBhwISpgGJAhKnAYsCGagBjAJZqQGOAhKqAZACGasBkQJarAGSAhKtAZMCEq4BlAIZrwGXAluwAZgCYbEBmQIUsgGaAhSzAZsCFLQBnAIUtQGdAhS2AZ8CFLcBoQIZuAGiAmK5AaQCFLoBpgIZuwGnAmO8AagCFL0BqQIUvgGqAhm_Aa0CZMABrgJowQGwAg7CAbECDsMBtAIOxAG1Ag7FAbYCDsYBuAIOxwG6AhnIAbsCackBvQIOygG_AhnLAcACaswBwQIOzQHCAg7OAcMCGc8BxgJr0AHHAnHRAckCENIBygIQ0wHNAhDUAc4CENUBzwIQ1gHRAhDXAdMCGdgB1AJy2QHWAhDaAdgCGdsB2QJz3AHaAhDdAdsCEN4B3AIZ3wHfAnTgAeACeuEB4QIJ4gHiAgnjAeMCCeQB5AIJ5QHlAgnmAecCCecB6QIZ6AHqAnvpAe0CCeoB7wIZ6wHwAnzsAfICCe0B8wIJ7gH0AhnvAfcCffAB-AKBAfEB-gKCAfIB-wKCAfMB_gKCAfQB_wKCAfUBgAOCAfYBggOCAfcBhAMZ-AGFA4MB-QGHA4IB-gGJAxn7AYoDhAH8AYsDggH9AYwDggH-AY0DGf8BkAOFAYACkQOJAYECkwOKAYIClAOKAYMClwOKAYQCmAOKAYUCmQOKAYYCmwOKAYcCnQMZiAKeA4sBiQKgA4oBigKiAxmLAqMDjAGMAqQDigGNAqUDigGOAqYDGY8CqQONAZACqgOTAZECrAOUAZICrQOUAZMCsAOUAZQCsQOUAZUCsgOUAZYCtAOUAZcCtgMZmAK3A5UBmQK5A5QBmgK7AxmbArwDlgGcAr0DlAGdAr4DlAGeAr8DGZ8CwgOXAaACwwOdAaECxAMKogLFAwqjAsYDCqQCxwMKpQLIAwqmAsoDCqcCzAMZqALNA54BqQLRAwqqAtMDGasC1AOfAawC1wMKrQLYAwquAtkDGa8C3AOgAbAC3QOkAbEC3gMLsgLfAwuzAuADC7QC4QMLtQLiAwu2AuQDC7cC5gMZuALnA6UBuQLpAwu6AusDGbsC7AOmAbwC7QMLvQLuAwu-Au8DGb8C8gOnAcAC8wOtAcEC9AMMwgL1AwzDAvYDDMQC9wMMxQL4AwzGAvoDDMcC_AMZyAL9A64ByQL_AwzKAoEEGcsCggSvAcwCgwQMzQKEBAzOAoUEGc8CiASwAdACiQS2AQ" } async function decodeBase64AsWasm(wasmBase64: string): Promise { diff --git a/src/lib/generated/prisma/internal/prismaNamespace.ts b/src/lib/generated/prisma/internal/prismaNamespace.ts index 2dc3566..3d7c867 100644 --- a/src/lib/generated/prisma/internal/prismaNamespace.ts +++ b/src/lib/generated/prisma/internal/prismaNamespace.ts @@ -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', diff --git a/src/lib/generated/prisma/internal/prismaNamespaceBrowser.ts b/src/lib/generated/prisma/internal/prismaNamespaceBrowser.ts index aa94cb6..ada708e 100644 --- a/src/lib/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/src/lib/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -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', diff --git a/src/lib/generated/prisma/models/Block.ts b/src/lib/generated/prisma/models/Block.ts index 28969e0..72b06e8 100644 --- a/src/lib/generated/prisma/models/Block.ts +++ b/src/lib/generated/prisma/models/Block.ts @@ -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 | 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 = runtime.Types.Extensions.GetSelect<{ id?: boolean scheduleId?: boolean + day?: boolean startMinutes?: boolean endMinutes?: boolean dynamicAudio?: boolean @@ -679,6 +713,7 @@ export type BlockSelect = runtime.Types.Extensions.GetSelect<{ id?: boolean scheduleId?: boolean + day?: boolean startMinutes?: boolean endMinutes?: boolean dynamicAudio?: boolean @@ -690,6 +725,7 @@ export type BlockSelectCreateManyAndReturn = runtime.Types.Extensions.GetSelect<{ id?: boolean scheduleId?: boolean + day?: boolean startMinutes?: boolean endMinutes?: boolean dynamicAudio?: boolean @@ -701,6 +737,7 @@ export type BlockSelectUpdateManyAndReturn = runtime.Types.Extensions.GetOmit<"id" | "scheduleId" | "startMinutes" | "endMinutes" | "dynamicAudio" | "createdAt" | "updatedAt", ExtArgs["result"]["block"]> +export type BlockOmit = runtime.Types.Extensions.GetOmit<"id" | "scheduleId" | "day" | "startMinutes" | "endMinutes" | "dynamicAudio" | "createdAt" | "updatedAt", ExtArgs["result"]["block"]> export type BlockInclude = { schedule?: boolean | Prisma.ScheduleDefaultArgs proclamations?: boolean | Prisma.Block$proclamationsArgs @@ -730,6 +767,7 @@ export type $BlockPayload 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'> diff --git a/src/lib/generated/prisma/models/LicenseKey.ts b/src/lib/generated/prisma/models/LicenseKey.ts index 3bba913..d8912ab 100644 --- a/src/lib/generated/prisma/models/LicenseKey.ts +++ b/src/lib/generated/prisma/models/LicenseKey.ts @@ -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 | null + schedule?: Prisma.XOR | 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 | null -}, "id" | "key"> + schedule?: Prisma.XOR | 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 + connectOrCreate?: Prisma.LicenseKeyCreateOrConnectWithoutScheduleInput + connect?: Prisma.LicenseKeyWhereUniqueInput +} + +export type LicenseKeyUpdateOneWithoutScheduleNestedInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.LicenseKeyCreateOrConnectWithoutScheduleInput + upsert?: Prisma.LicenseKeyUpsertWithoutScheduleInput + disconnect?: Prisma.LicenseKeyWhereInput | boolean + delete?: Prisma.LicenseKeyWhereInput | boolean + connect?: Prisma.LicenseKeyWhereUniqueInput + update?: Prisma.XOR, 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 +} + +export type LicenseKeyUpsertWithoutScheduleInput = { + update: Prisma.XOR + create: Prisma.XOR + where?: Prisma.LicenseKeyWhereInput +} + +export type LicenseKeyUpdateToOneWithWhereWithoutScheduleInput = { + where?: Prisma.LicenseKeyWhereInput + data: Prisma.XOR +} + +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 + schedule?: boolean | Prisma.LicenseKey$scheduleArgs }, ExtArgs["result"]["licenseKey"]> export type LicenseKeySelectCreateManyAndReturn = runtime.Types.Extensions.GetSelect<{ @@ -517,6 +680,8 @@ export type LicenseKeySelectCreateManyAndReturn }, ExtArgs["result"]["licenseKey"]> @@ -528,6 +693,8 @@ export type LicenseKeySelectUpdateManyAndReturn }, ExtArgs["result"]["licenseKey"]> @@ -539,11 +706,14 @@ export type LicenseKeySelectScalar = { lastValidatedAt?: boolean userId?: boolean email?: boolean + systemName?: boolean + systemNameLower?: boolean } -export type LicenseKeyOmit = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email", ExtArgs["result"]["licenseKey"]> +export type LicenseKeyOmit = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email" | "systemName" | "systemNameLower", ExtArgs["result"]["licenseKey"]> export type LicenseKeyInclude = { user?: boolean | Prisma.LicenseKey$userArgs + schedule?: boolean | Prisma.LicenseKey$scheduleArgs } export type LicenseKeyIncludeCreateManyAndReturn = { user?: boolean | Prisma.LicenseKey$userArgs @@ -556,6 +726,7 @@ export type $LicenseKeyPayload | null + schedule: Prisma.$SchedulePayload | null } scalars: runtime.Types.Extensions.GetPayloadResult<{ id: string @@ -565,6 +736,8 @@ export type $LicenseKeyPayload composites: {} } @@ -960,6 +1133,7 @@ readonly fields: LicenseKeyFieldRefs; export interface Prisma__LicenseKeyClient extends Prisma.PrismaPromise { readonly [Symbol.toStringTag]: "PrismaPromise" user = {}>(args?: Prisma.Subset>): Prisma.Prisma__UserClient, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + schedule = {}>(args?: Prisma.Subset>): Prisma.Prisma__ScheduleClient, 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 = { + /** + * Select specific fields to fetch from the Schedule + */ + select?: Prisma.ScheduleSelect | null + /** + * Omit specific fields from the Schedule + */ + omit?: Prisma.ScheduleOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.ScheduleInclude | null + where?: Prisma.ScheduleWhereInput +} + /** * LicenseKey without action */ diff --git a/src/lib/generated/prisma/models/Schedule.ts b/src/lib/generated/prisma/models/Schedule.ts index 88ca289..972dd15 100644 --- a/src/lib/generated/prisma/models/Schedule.ts +++ b/src/lib/generated/prisma/models/Schedule.ts @@ -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 | 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 + licenseKey?: Prisma.XOR | null + user?: Prisma.XOR | 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 + licenseKey?: Prisma.XOR | null + user?: Prisma.XOR | 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 - connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput +export type ScheduleCreateNestedManyWithoutUserInput = { + create?: Prisma.XOR | 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[] + connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[] + createMany?: Prisma.ScheduleCreateManyUserInputEnvelope + connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[] +} + +export type ScheduleUpdateManyWithoutUserNestedInput = { + create?: Prisma.XOR | 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[] + 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 + connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput connect?: Prisma.ScheduleWhereUniqueInput } -export type ScheduleUncheckedCreateNestedOneWithoutUserInput = { - create?: Prisma.XOR - connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput +export type ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput connect?: Prisma.ScheduleWhereUniqueInput } -export type ScheduleUpdateOneWithoutUserNestedInput = { - create?: Prisma.XOR - connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput - upsert?: Prisma.ScheduleUpsertWithoutUserInput +export type ScheduleUpdateOneWithoutLicenseKeyNestedInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput + upsert?: Prisma.ScheduleUpsertWithoutLicenseKeyInput disconnect?: Prisma.ScheduleWhereInput | boolean delete?: Prisma.ScheduleWhereInput | boolean connect?: Prisma.ScheduleWhereUniqueInput - update?: Prisma.XOR, Prisma.ScheduleUncheckedUpdateWithoutUserInput> + update?: Prisma.XOR, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput> } -export type ScheduleUncheckedUpdateOneWithoutUserNestedInput = { - create?: Prisma.XOR - connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput - upsert?: Prisma.ScheduleUpsertWithoutUserInput +export type ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput + upsert?: Prisma.ScheduleUpsertWithoutLicenseKeyInput disconnect?: Prisma.ScheduleWhereInput | boolean delete?: Prisma.ScheduleWhereInput | boolean connect?: Prisma.ScheduleWhereUniqueInput - update?: Prisma.XOR, Prisma.ScheduleUncheckedUpdateWithoutUserInput> + update?: Prisma.XOR, 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 } -export type ScheduleUpsertWithoutUserInput = { - update: Prisma.XOR - create: Prisma.XOR - 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 + create: Prisma.XOR +} + +export type ScheduleUpdateWithWhereUniqueWithoutUserInput = { + where: Prisma.ScheduleWhereUniqueInput data: Prisma.XOR } -export type ScheduleUpdateWithoutUserInput = { +export type ScheduleUpdateManyWithWhereWithoutUserInput = { + where: Prisma.ScheduleScalarWhereInput + data: Prisma.XOR +} + +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 +} + +export type ScheduleUpsertWithoutLicenseKeyInput = { + update: Prisma.XOR + create: Prisma.XOR + where?: Prisma.ScheduleWhereInput +} + +export type ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput = { + where?: Prisma.ScheduleWhereInput + data: Prisma.XOR +} + +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 = runtime.Types.Extensions.GetSelect<{ id?: boolean + licenseKeyId?: boolean userId?: boolean + type?: boolean createdAt?: boolean updatedAt?: boolean - user?: boolean | Prisma.UserDefaultArgs + licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs + user?: boolean | Prisma.Schedule$userArgs blocks?: boolean | Prisma.Schedule$blocksArgs _count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs }, ExtArgs["result"]["schedule"]> export type ScheduleSelectCreateManyAndReturn = runtime.Types.Extensions.GetSelect<{ id?: boolean + licenseKeyId?: boolean userId?: boolean + type?: boolean createdAt?: boolean updatedAt?: boolean - user?: boolean | Prisma.UserDefaultArgs + licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs + user?: boolean | Prisma.Schedule$userArgs }, ExtArgs["result"]["schedule"]> export type ScheduleSelectUpdateManyAndReturn = runtime.Types.Extensions.GetSelect<{ id?: boolean + licenseKeyId?: boolean userId?: boolean + type?: boolean createdAt?: boolean updatedAt?: boolean - user?: boolean | Prisma.UserDefaultArgs + licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs + user?: boolean | Prisma.Schedule$userArgs }, ExtArgs["result"]["schedule"]> export type ScheduleSelectScalar = { id?: boolean + licenseKeyId?: boolean userId?: boolean + type?: boolean createdAt?: boolean updatedAt?: boolean } -export type ScheduleOmit = runtime.Types.Extensions.GetOmit<"id" | "userId" | "createdAt" | "updatedAt", ExtArgs["result"]["schedule"]> +export type ScheduleOmit = runtime.Types.Extensions.GetOmit<"id" | "licenseKeyId" | "userId" | "type" | "createdAt" | "updatedAt", ExtArgs["result"]["schedule"]> export type ScheduleInclude = { - user?: boolean | Prisma.UserDefaultArgs + licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs + user?: boolean | Prisma.Schedule$userArgs blocks?: boolean | Prisma.Schedule$blocksArgs _count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs } export type ScheduleIncludeCreateManyAndReturn = { - user?: boolean | Prisma.UserDefaultArgs + licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs + user?: boolean | Prisma.Schedule$userArgs } export type ScheduleIncludeUpdateManyAndReturn = { - user?: boolean | Prisma.UserDefaultArgs + licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs + user?: boolean | Prisma.Schedule$userArgs } export type $SchedulePayload = { name: "Schedule" objects: { - user: Prisma.$UserPayload + licenseKey: Prisma.$LicenseKeyPayload | null + user: Prisma.$UserPayload | null blocks: Prisma.$BlockPayload[] } 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 extends Prisma.PrismaPromise { readonly [Symbol.toStringTag]: "PrismaPromise" - user = {}>(args?: Prisma.Subset>): Prisma.Prisma__UserClient, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> + licenseKey = {}>(args?: Prisma.Subset>): Prisma.Prisma__LicenseKeyClient, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + user = {}>(args?: Prisma.Subset>): Prisma.Prisma__UserClient, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> blocks = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> /** * Attaches callbacks for the resolution and/or rejection of the Promise. @@ -950,7 +1172,9 @@ export interface Prisma__ScheduleClient + 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 = { + /** + * Select specific fields to fetch from the LicenseKey + */ + select?: Prisma.LicenseKeySelect | null + /** + * Omit specific fields from the LicenseKey + */ + omit?: Prisma.LicenseKeyOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.LicenseKeyInclude | null + where?: Prisma.LicenseKeyWhereInput +} + +/** + * Schedule.user + */ +export type Schedule$userArgs = { + /** + * Select specific fields to fetch from the User + */ + select?: Prisma.UserSelect | null + /** + * Omit specific fields from the User + */ + omit?: Prisma.UserOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.UserInclude | null + where?: Prisma.UserWhereInput +} + /** * Schedule.blocks */ diff --git a/src/lib/generated/prisma/models/User.ts b/src/lib/generated/prisma/models/User.ts index 7ee86a5..5254841 100644 --- a/src/lib/generated/prisma/models/User.ts +++ b/src/lib/generated/prisma/models/User.ts @@ -246,7 +246,7 @@ export type UserWhereInput = { profiles?: Prisma.ProfileListRelationFilter licenseKeys?: Prisma.LicenseKeyListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter - schedule?: Prisma.XOR | 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 | 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.UserUncheckedUpdateWithoutLicenseKeysInput> } -export type UserCreateNestedOneWithoutScheduleInput = { - create?: Prisma.XOR - connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput +export type UserCreateNestedOneWithoutSchedulesInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput connect?: Prisma.UserWhereUniqueInput } -export type UserUpdateOneRequiredWithoutScheduleNestedInput = { - create?: Prisma.XOR - connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput - upsert?: Prisma.UserUpsertWithoutScheduleInput +export type UserUpdateOneWithoutSchedulesNestedInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput + upsert?: Prisma.UserUpsertWithoutSchedulesInput + disconnect?: Prisma.UserWhereInput | boolean + delete?: Prisma.UserWhereInput | boolean connect?: Prisma.UserWhereUniqueInput - update?: Prisma.XOR, Prisma.UserUncheckedUpdateWithoutScheduleInput> + update?: Prisma.XOR, 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 + create: Prisma.XOR } -export type UserUpsertWithoutScheduleInput = { - update: Prisma.XOR - create: Prisma.XOR +export type UserUpsertWithoutSchedulesInput = { + update: Prisma.XOR + create: Prisma.XOR where?: Prisma.UserWhereInput } -export type UserUpdateToOneWithWhereWithoutScheduleInput = { +export type UserUpdateToOneWithWhereWithoutSchedulesInput = { where?: Prisma.UserWhereInput - data: Prisma.XOR + data: Prisma.XOR } -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 = { @@ -1256,6 +1259,7 @@ export type UserCountOutputTypeSelect = { + where?: Prisma.ScheduleWhereInput +} + export type UserSelect = runtime.Types.Extensions.GetSelect<{ id?: boolean @@ -1319,7 +1330,7 @@ export type UserSelect licenseKeys?: boolean | Prisma.User$licenseKeysArgs userProclamations?: boolean | Prisma.User$userProclamationsArgs - schedule?: boolean | Prisma.User$scheduleArgs + schedules?: boolean | Prisma.User$schedulesArgs _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs }, ExtArgs["result"]["user"]> @@ -1366,7 +1377,7 @@ export type UserInclude licenseKeys?: boolean | Prisma.User$licenseKeysArgs userProclamations?: boolean | Prisma.User$userProclamationsArgs - schedule?: boolean | Prisma.User$scheduleArgs + schedules?: boolean | Prisma.User$schedulesArgs _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs } export type UserIncludeCreateManyAndReturn = { @@ -1385,7 +1396,7 @@ export type $UserPayload[] licenseKeys: Prisma.$LicenseKeyPayload[] userProclamations: Prisma.$UserProclamationsPayload[] - schedule: Prisma.$SchedulePayload | null + schedules: Prisma.$SchedulePayload[] } scalars: runtime.Types.Extensions.GetPayloadResult<{ id: string @@ -1796,7 +1807,7 @@ export interface Prisma__UserClient = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> licenseKeys = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> userProclamations = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> - schedule = {}>(args?: Prisma.Subset>): Prisma.Prisma__ScheduleClient, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + schedules = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, 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 = { +export type User$schedulesArgs = { /** * Select specific fields to fetch from the Schedule */ @@ -2390,6 +2401,11 @@ export type User$scheduleArgs | null where?: Prisma.ScheduleWhereInput + orderBy?: Prisma.ScheduleOrderByWithRelationInput | Prisma.ScheduleOrderByWithRelationInput[] + cursor?: Prisma.ScheduleWhereUniqueInput + take?: number + skip?: number + distinct?: Prisma.ScheduleScalarFieldEnum | Prisma.ScheduleScalarFieldEnum[] } /** diff --git a/src/lib/schedule-utils.spec.ts b/src/lib/schedule-utils.spec.ts index afa03bd..b0317d0 100644 --- a/src/lib/schedule-utils.spec.ts +++ b/src/lib/schedule-utils.spec.ts @@ -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(); + }); +}); diff --git a/src/lib/schedule-utils.ts b/src/lib/schedule-utils.ts index 2f16e65..9fa2682 100644 --- a/src/lib/schedule-utils.ts +++ b/src/lib/schedule-utils.ts @@ -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; diff --git a/src/lib/server/p5-e2e.integration.spec.ts b/src/lib/server/p5-e2e.integration.spec.ts new file mode 100644 index 0000000..f7d2203 --- /dev/null +++ b/src/lib/server/p5-e2e.integration.spec.ts @@ -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 { + 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>) { + 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); + }); +}); diff --git a/src/lib/server/schedule-copy.spec.ts b/src/lib/server/schedule-copy.spec.ts new file mode 100644 index 0000000..6ae91d2 --- /dev/null +++ b/src/lib/server/schedule-copy.spec.ts @@ -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 }) => { + 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 }) => { + if (state.failOnBlockCreate) throw new Error('db down'); + const { proclamations, ...rest } = data as { + proclamations: { create: { proclamationId: string; sortOrder: number }[] }; + } & Record; + 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) => { + 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) => ({ + 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) => ({ + 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[] = []; + for (const [day, list] of Object.entries( + fixture.weekly as Record[]> + )) { + 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) + .filter(([, list]) => list.length > 0) + .map(([day]) => day) + .sort(); + expect(nonEmptyDays).toEqual(fixtureDays); + }); +}); diff --git a/src/lib/server/schedule.spec.ts b/src/lib/server/schedule.spec.ts index 150af0e..f4d79f8 100644 --- a/src/lib/server/schedule.spec.ts +++ b/src/lib/server/schedule.spec.ts @@ -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; const scheduleCreate = prisma.schedule.create as ReturnType; +const scheduleUpdate = prisma.schedule.update as ReturnType; const blockFindMany = prisma.block.findMany as ReturnType; const blockFindFirst = prisma.block.findFirst as ReturnType; const blockCreate = prisma.block.create as ReturnType; const blockUpdate = prisma.block.update as ReturnType; const blockDelete = prisma.block.delete as ReturnType; +const blockDeleteMany = prisma.block.deleteMany as ReturnType; 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' } }); diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts index 48149b7..389fd90 100644 --- a/src/lib/server/schedule.ts +++ b/src/lib/server/schedule.ts @@ -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 { + if (type !== null && !isScheduleType(type)) { + return { ok: false, error: 'Pick a schedule type.' }; + } + + const schedule = await getOrCreateScheduleForLicense(licenseKeyId); + if (schedule.type === type) return { ok: true }; + + const existing = await prisma.block.findMany({ + where: { scheduleId: schedule.id }, + include: { proclamations: { orderBy: { sortOrder: 'asc' } } } + }); + + // Changing an existing, populated schedule requires confirmation. + if ( + (schedule.type === 'daily' || schedule.type === 'weekly') && + type !== null && + !opts.confirmed + ) { + return { ok: false, error: 'confirm' }; + } + + if (type === 'weekly' && schedule.type === 'daily' && existing.length > 0) { + // Prefill all seven days with a copy of the daily Blocks. + await prisma.block.createMany({ + data: existing.flatMap((block) => + WEEK_DAYS.map((day) => ({ + scheduleId: schedule.id, + day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio + })) + ) + }); + const clones = await prisma.block.findMany({ + where: { scheduleId: schedule.id, day: { not: null } } + }); + for (const clone of clones) { + const source = existing.find( + (b) => + b.startMinutes === clone.startMinutes && + b.endMinutes === clone.endMinutes && + b.dynamicAudio === clone.dynamicAudio + ); + if (source) { + await prisma.blockProclamations.createMany({ + data: source.proclamations.map((p, index) => ({ + blockId: clone.id, + proclamationId: p.proclamationId, + sortOrder: index + })), + skipDuplicates: true + }); } } - }); + } else if (type !== 'weekly') { + // weekly→daily, daily→null, weekly→null: clear the Blocks. + await prisma.block.deleteMany({ where: { scheduleId: schedule.id } }); + } + + await prisma.schedule.update({ where: { id: schedule.id }, data: { type } }); + return { ok: true }; } -export async function createBlock(userId: string, input: BlockInput): Promise { - const schedule = await getOrCreateSchedule(userId); - const siblings = await prisma.block.findMany({ - where: { scheduleId: schedule.id }, - select: { id: true, startMinutes: true, endMinutes: true } +async function ensureValid( + licenseKeyId: string, + day: string | null, + candidate: BlockInput, + excludeBlockId?: string +): Promise { + 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 validation = validateBlock(input, siblings, { isCreate: 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 { + 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 { 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 { +export async function deleteBlock( + licenseKeyId: string, + blockId: string +): Promise { 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 { + 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 { + 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) }; + }) + }; +} diff --git a/src/lib/server/system-name.spec.ts b/src/lib/server/system-name.spec.ts new file mode 100644 index 0000000..d40cba9 --- /dev/null +++ b/src/lib/server/system-name.spec.ts @@ -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; +const keyUpdate = prisma.licenseKey.update as ReturnType; + +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'); + }); +}); diff --git a/src/lib/server/system-name.ts b/src/lib/server/system-name.ts new file mode 100644 index 0000000..6c45d9e --- /dev/null +++ b/src/lib/server/system-name.ts @@ -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 { + 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; + } +} diff --git a/src/lib/server/v2-schedule.spec.ts b/src/lib/server/v2-schedule.spec.ts new file mode 100644 index 0000000..28d2d08 --- /dev/null +++ b/src/lib/server/v2-schedule.spec.ts @@ -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[]) => + [...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); + }); +}); diff --git a/src/lib/server/v2-serializers.spec.ts b/src/lib/server/v2-serializers.spec.ts index 2e3a102..0b94e3a 100644 --- a/src/lib/server/v2-serializers.spec.ts +++ b/src/lib/server/v2-serializers.spec.ts @@ -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'); }); diff --git a/src/lib/server/v2-serializers.ts b/src/lib/server/v2-serializers.ts index 5700851..d58d2b6 100644 --- a/src/lib/server/v2-serializers.ts +++ b/src/lib/server/v2-serializers.ts @@ -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; + 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 = {}; + + 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 : [] + }; +} diff --git a/src/routes/(protected)/(user)/dashboard/+page.svelte b/src/routes/(protected)/(user)/dashboard/+page.svelte index f0b0166..83e33cc 100644 --- a/src/routes/(protected)/(user)/dashboard/+page.svelte +++ b/src/routes/(protected)/(user)/dashboard/+page.svelte @@ -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" > + System Name Key Status Created @@ -53,6 +54,13 @@ {#each data.licenseKeys as key (key.key)} + + {#if key.systemName} + {key.systemName} + {:else} + Unnamed system + {/if} + {key.key} {#if key.active} @@ -104,6 +112,9 @@ {/if}
+ + {key.systemName ?? 'Unnamed system'} + Created {formatDate(key.createdAt)} diff --git a/src/routes/(protected)/(user)/schedule/+page.server.ts b/src/routes/(protected)/(user)/schedule/+page.server.ts index 10359d1..9f5d908 100644 --- a/src/routes/(protected)/(user)/schedule/+page.server.ts +++ b/src/routes/(protected)/(user)/schedule/+page.server.ts @@ -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,27 +69,90 @@ export const load: PageServerLoad = async ({ locals }) => { }) ]); - const blockViews = blocks.map((block) => ({ - id: block.id, - startMinutes: block.startMinutes, - endMinutes: block.endMinutes, - dynamicAudio: block.dynamicAudio, - proclamations: block.proclamations.map((entry) => ({ - id: entry.proclamation.id, - name: entry.proclamation.name, - categoryId: entry.proclamation.categoryId, - groupId: entry.proclamation.groupId - })) + // 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) })); - return { blocks: blockViews, categories, groups, proclamations }; + const requested = url.searchParams.get('license'); + const selected = licenseViews.find((l) => l.id === requested) ?? licenseViews[0] ?? null; + + let type: 'daily' | 'weekly' | null = null; + let blocks: { + id: string; + day: string | null; + startMinutes: number; + endMinutes: number; + dynamicAudio: boolean; + proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[]; + }[] = []; + + if (selected) { + const schedule = await getScheduleForLicense(selected.id); + if (schedule) { + type = schedule.type === 'daily' || schedule.type === 'weekly' ? schedule.type : null; + blocks = schedule.blocks.map((block) => ({ + id: block.id, + day: block.day, + startMinutes: block.startMinutes, + endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio, + proclamations: block.proclamations.map((entry) => ({ + id: entry.proclamation.id, + name: entry.proclamation.name, + categoryId: entry.proclamation.categoryId, + groupId: entry.proclamation.groupId + })) + })); + } + } + + return { + licenses: licenseViews, + selectedLicenseId: selected?.id ?? null, + type, + blocks, + categories, + groups, + proclamations + }; }; export const actions: Actions = { + setType: async ({ request, locals }) => { + if (!locals.user?.id) throw error(403, 'not authorized'); + const formData = await request.formData(); + const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? ''; + const rawType = formData.get('type')?.toString() ?? ''; + const type = rawType === 'daily' || rawType === 'weekly' ? rawType : null; + const confirm = formData.get('confirm') === 'true'; + + const license = await ownedLicense(locals.user.id, licenseKeyId); + if (!license) return fail(404, { error: 'System not found.' }); + + const result = await setScheduleType(licenseKeyId, type, { confirmed: confirm }); + if (!result.ok) return fail(400, { error: result.error }); + return { success: true }; + }, + createBlock: async ({ request, locals }) => { if (!locals.user?.id) throw error(403, 'not authorized'); - const 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 + }; } }; diff --git a/src/routes/(protected)/(user)/schedule/+page.svelte b/src/routes/(protected)/(user)/schedule/+page.svelte index 1f37e36..5e8b4ef 100644 --- a/src/routes/(protected)/(user)/schedule/+page.svelte +++ b/src/routes/(protected)/(user)/schedule/+page.svelte @@ -1,32 +1,138 @@ + +
@@ -60,14 +217,23 @@ Schedule

- Build your daily listening schedule. Up to 4 blocks per day. + Build a listening schedule for a system. Up to 4 blocks per day.

- - {data.blocks.length} of 4 blocks - + {#if data.licenses.length > 0} + + {/if}
{#if successMessage} @@ -77,24 +243,273 @@ {successMessage}
{/if} + {#if errorMessage} + + {/if} -
- -
+ {#if !selectedLicense} +
+

+ You don't have any systems yet. A system appears here once a Kiosk validates its License + Key. +

+
+ {:else} + +
+
+ System +

+ {licenseLabel} + {#if !selectedLicense.systemName} + (unnamed system) + {/if} +

+
+
+ Schedule type +
+ + +
+ {#if type !== null} + + {#if copyCandidates.length > 0} + + {/if} + {/if} +
+
- + {#if type === null} +
+

+ No schedule. Choose Daily or Weekly to start. +

+
+
+ 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. +
+ {:else if type === 'daily'} +
+ openCreate(null, s, e)} + onEdit={openEdit} + /> +
+ {:else} +
+ openCreate(day, s, e)} + onEdit={openEdit} + /> +
+ {/if} + +

+ 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. +

+ + + {/if}
+ +{#if showTypeConfirm} +
+
+

Change schedule type?

+

+ 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} +

+
+ + +
+
+
+{/if} + +{#if copyOpen} +
+
+
+

+ Copy schedule to… +

+ +
+ +
+

+ Copy {selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : ''}'s schedule to other systems. Copies are independent snapshots. +

+ + {#if copyError} + + {/if} + +
    + {#each copyCandidates as candidate (candidate.id)} +
  • + +
  • + {/each} +
+ + {#if needsCopyConfirm} +
+ This will overwrite the existing schedule on + {overwritingTargets.map((t) => t.systemName ?? t.key).join(', ')}. +
+ {/if} +
+ +
+ + +
+ + +
+
+{/if} diff --git a/src/routes/(protected)/(user)/schedule/page.server.spec.ts b/src/routes/(protected)/(user)/schedule/page.server.spec.ts index 4d6eab0..cc6a96e 100644 --- a/src/routes/(protected)/(user)/schedule/page.server.spec.ts +++ b/src/routes/(protected)/(user)/schedule/page.server.spec.ts @@ -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; -const listBlocksMock = listBlocks as ReturnType; +const licenseFindMany = prisma.licenseKey.findMany as ReturnType; +const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType; +const scheduleFindMany = prisma.schedule.findMany as ReturnType; +const getScheduleMock = getScheduleForLicense as ReturnType; +const setTypeMock = setScheduleType as ReturnType; const createBlockMock = createBlock as ReturnType; const deleteBlockMock = deleteBlock as ReturnType; +const copyScheduleMock = copySchedule as ReturnType; 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,34 +80,37 @@ describe('schedule load', () => { }); it('maps blocks to title/category/group only', async () => { - listBlocksMock.mockResolvedValue([ - { - id: 'b1', - startMinutes: 360, - endMinutes: 480, - dynamicAudio: true, - proclamations: [ - { - proclamation: { - id: 'p1', - name: 'Peace', - categoryId: 2, - groupId: 1, - content: '

secret

' + getScheduleMock.mockResolvedValue({ + id: 's1', + type: 'daily', + blocks: [ + { + id: 'b1', + day: null, + startMinutes: 360, + endMinutes: 480, + dynamicAudio: true, + proclamations: [ + { + proclamation: { + id: 'p1', + name: 'Peace', + categoryId: 2, + groupId: 1, + content: '

secret

' + } } - } - ] - } - ]); + ] + } + ] + }); - const result = (await load({ locals } as never)) as { - blocks: { - id: string; - dynamicAudio: boolean; - proclamations: Record[]; - }[]; + const result = (await load({ locals, url: new URL('http://x/') } as never)) as { + type: string | null; + blocks: { id: string; dynamicAudio: boolean; proclamations: Record[] }[]; }; + 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.' }); + } + }); }); diff --git a/src/routes/(protected)/(user)/schedule/page.svelte.spec.ts b/src/routes/(protected)/(user)/schedule/page.svelte.spec.ts new file mode 100644 index 0000000..cf840d2 --- /dev/null +++ b/src/routes/(protected)/(user)/schedule/page.svelte.spec.ts @@ -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'] { + 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(); + }); +}); diff --git a/src/routes/(protected)/admin/license-keys/+page.svelte b/src/routes/(protected)/admin/license-keys/+page.svelte index e79d2b1..3c09763 100644 --- a/src/routes/(protected)/admin/license-keys/+page.svelte +++ b/src/routes/(protected)/admin/license-keys/+page.svelte @@ -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 | undefined)?.name ?? '' diff --git a/src/routes/api/v2/license/+server.ts b/src/routes/api/v2/license/+server.ts index b21644f..c7b9acc 100644 --- a/src/routes/api/v2/license/+server.ts +++ b/src/routes/api/v2/license/+server.ts @@ -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 }) ); }; diff --git a/src/routes/api/v2/license/server.spec.ts b/src/routes/api/v2/license/server.spec.ts index 3fde419..cf63304 100644 --- a/src/routes/api/v2/license/server.spec.ts +++ b/src/routes/api/v2/license/server.spec.ts @@ -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; 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 = { + 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); + }); +}); diff --git a/src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts b/src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts new file mode 100644 index 0000000..736903f --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts @@ -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 } + }); +}; diff --git a/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts b/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts new file mode 100644 index 0000000..0cd0f86 --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/schedule/server.spec.ts @@ -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[0]; + +const mockFindUnique = prisma.licenseKey.findUnique as ReturnType; +const mockGetSchedule = getScheduleForLicense as ReturnType; +const mockToInput = scheduleBlocksToInput as ReturnType; + +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; + 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); + }); +}); diff --git a/src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts b/src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts new file mode 100644 index 0000000..f6cdc46 --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts @@ -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; + + 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' }); +}; diff --git a/src/routes/api/v2/licenses/[licenseKey]/system-name/server.spec.ts b/src/routes/api/v2/licenses/[licenseKey]/system-name/server.spec.ts new file mode 100644 index 0000000..d2949fe --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/system-name/server.spec.ts @@ -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[0]; + +const mockFindUnique = prisma.licenseKey.findUnique as ReturnType; +const mockFindFirst = prisma.licenseKey.findFirst as ReturnType; +const mockUpdate = prisma.licenseKey.update as ReturnType; + +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(); + }); +}); diff --git a/src/routes/api/v2/schedule/+server.ts b/src/routes/api/v2/schedule/+server.ts index da4d212..b0eff46 100644 --- a/src/routes/api/v2/schedule/+server.ts +++ b/src/routes/api/v2/schedule/+server.ts @@ -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 }); }; diff --git a/src/routes/api/v2/schedule/schedule.spec.ts b/src/routes/api/v2/schedule/schedule.spec.ts index cf26dee..228e8ae 100644 --- a/src/routes/api/v2/schedule/schedule.spec.ts +++ b/src/routes/api/v2/schedule/schedule.spec.ts @@ -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; const userFindUnique = prisma.user.findUnique as ReturnType; -const blockFindMany = prisma.block.findMany as ReturnType; +const getScheduleMock = getScheduleForLicense as ReturnType; +const toInputMock = scheduleBlocksToInput as ReturnType; function call(params: Record) { 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,52 +99,62 @@ 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: [ - { - id: 'block-1', - startTime: '21:00', - startMinutes: 1260, - endTime: '09:00', - endMinutes: 540, - overnight: true, - durationMinutes: 720, - dynamicAudio: true, - proclamationIds: ['p1', 'p2'] - } - ] - }); + const body = (await response.json()) as { email: string; blocks: unknown[]; type: string }; + expect(body.email).toBe('jane@example.com'); + expect(body.blocks).toEqual([ + { + id: 'block-1', + startTime: '21:00', + startMinutes: 1260, + endTime: '09:00', + endMinutes: 540, + overnight: true, + durationMinutes: 720, + dynamicAudio: true, + proclamationIds: ['p1', 'p2'] + } + ]); + // Additive fields for new Kiosks. + expect(body.type).toBe('daily'); + expect(body).toHaveProperty('version'); + expect(body).toHaveProperty('daily'); + expect(body).toHaveProperty('weekly'); }); it('returns IDs for every selected proclamation regardless of frequency', async () => { - licenseKeyFindUnique.mockResolvedValue({ 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(); }); });