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-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/schedule-api-contract.md b/docs/schedule-api-contract.md new file mode 100644 index 0000000..9e0dda6 --- /dev/null +++ b/docs/schedule-api-contract.md @@ -0,0 +1,546 @@ +# 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 (names & purpose only) + +Finalized in Phases 3 and 4. They are **not** Kiosk-facing. + +- `POST /api/v2/...` / server actions for Schedule CRUD (create/update/delete + Block, set `type`, assign Proclamations) — Phase 3. +- `POST .../copy` to copy a Schedule between License Keys on the same account — + Phase 4. + +Exact paths and payloads are deferred; they will be recorded here when added. + +--- + +## 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 and conservative choices. Each is subject to change in a +later phase. + +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". +3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained; the + brief does not state a cap. Whether weekly allows 4/day or 4 total 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.** Algorithm (content hash vs max `updatedAt` + + shape) deferred to implementation; it must be stable for equal content and + change on 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 changes `false` → `true` for + **new** Blocks; existing Blocks are left at their stored values (no + backfill assumed). +8. **Per-user → per-License-Key migration.** Accounts with multiple License + Keys: which key inherits the existing per-user Schedule is unresolved; + conservative default is the account's first/oldest key. +9. **Cross-day spillover validation.** Weekly `sun` spillover into `mon` (and + daily same-list spillover) must be validated; exact error messaging is + deferred. +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. +11. **Geoip timezone display.** Informational only, not implemented in the + Portal today. +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.** The brief's boot/network-up/5-minute defaults are + targets; the current Kiosk has no such timer. +16. **Atomic cache file.** Path and format deferred; SQLite is the current + durable store. +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. + +--- + +## 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.