Files
portal/docs/schedule-api-contract.md
T

620 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Schedule API & Data Contract
Status: Phase 0 (contract only — no application code). This document is the
authoritative API/data contract for the multi-phase scheduler overhaul. It is
kept **byte-identical** in both repositories:
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/schedule-api-contract.md`
- `portal` (SvelteKit/Node) — `docs/schedule-api-contract.md`
Any change is applied to both copies in the same step, with a dated line
appended to the [Changelog](#changelog).
Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule,
License Key, System Name) are used with the meanings defined in the scheduler
overhaul brief.
---
## Current state
This section reflects the code as it exists at the start of Phase 0. It is
descriptive, not aspirational; the target behaviour is defined in the later
sections.
### Kiosk (`radiantwave`)
- **License validation.** `POST /api/v2/license` with body
`{"license_key": "...", "email_address": "..."}`. Implemented in
`src/network/api_client.cpp` (`validateLicense`, `parseLicenseJson`) and
consumed by `src/network/license_service.cpp` (`validateStoredLicense`,
`markLicenseVerified`, `isLicenseWithinGracePeriod`). A 2xx response is the
device's "subscribed" flag; a 7-day offline grace window is stored as
`license_last_verified` in `system_kv` (`src/db/db_keys.h`). Response body
currently read: `{key, email_address}`; additional fields are ignored.
- **Schedule fetch.** `GET /api/v2/schedule?email=<email>&licenseKey=<key>`
(`src/network/api_client.cpp`: `fetchSchedule`, `parseScheduleJson`;
`PATH_SCHEDULE = "/schedule"`). Response shape read by the Kiosk:
`{email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes,
dynamicAudio, proclamationIds}]}`. `dynamicAudio` is optional on the wire and
defaults to `true` in `parseScheduleJson`. Missing/blank credentials cause an
early `false`.
- **Schedule persistence.** SQLite tables `schedule_blocks` and
`schedule_block_proclamations` (`src/db/database_manager.cpp:185`,
`replaceScheduleBlocks` at `:1105`, `getScheduleBlocks` at `:1169`). Full
replace; block `sort_order` and per-block proclamation `position` preserved.
No day-of-week column and no schedule `type`; the model is implicitly daily.
- **Schedule sync.** `syncScheduleFromApi` (`src/network/proclamation_sync.cpp:172`)
is invoked only as a **best-effort** side effect of
`syncProclamationsFromApi` (`:168`). There is no boot sync, no network-up
sync, no periodic timer, and no conditional GET (`If-None-Match`/ETag).
`syncProclamationsFromApi` is called from `Home::syncProclamationsIfEmpty`
(`src/home.cpp:461`) and `ProclamationOptionsPage::onPush`
(`src/proclamation_options_page.cpp:49`). There is no explicit atomic
schedule cache file; the SQLite database is the durable store.
- **Schedule playback.** `src/scroller_page.cpp` selects the active block by
comparing `timezone::currentLocalMinutes(cfg.timezone)`
(`src/timezone_helper.h`) against each block via
`scroller::minuteInScheduleBlock` (`src/scroller_streams.h:31`). Block
selection is daily-only (no day-of-week). An active block's ordered
`proclamation_ids` replace the local selection
(`src/scroller_streams.cpp:57`). `effectiveDynamicAudio()`
(`src/scroller_page.cpp:302`) forces dynamic audio when the active block has
`dynamic_audio == true`, otherwise the device-wide
`config.audio.dynamic_audio` applies. A block change is latched in
`scheduleChangePending_` and applied at the next proclamation boundary.
- **Settings persistence.** `~/.local/radiantwave/config.yaml` (YAML) via
`ConfigManager` (`src/config/config_manager.cpp`, `src/config/yaml_parser.cpp`,
schema in `src/config/app_config.h`): `timezone`, `audio.dynamic_audio`,
`proclamation.schedule_active` (Kiosk-local on/off toggle, default `false`).
Identity lives in SQLite `system_kv` (`email_address`, `license_key`,
`license_last_verified`). There is no System Name anywhere.
- **Conventions.** `API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"`
(`src/network/api_client.h`). Kiosk API knowledge is mirrored in
`openapi.yaml` at the repo root.
### Portal (`portal`)
- **Framework/tooling.** SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4;
Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate
(`prisma/schema.prisma`, `prisma/migrations/`, `npm run migrate`,
`npm run deploy`; config in `prisma.config.ts`). API routes under
`src/routes/api/v1/` (API-key gated) and `src/routes/api/v2/`
(unauthenticated). v2 helpers: `src/lib/server/v2-helpers.ts`
(`jsonOk`, `jsonError`, `readJsonBody`, `rateLimited`, `clientIp`); errors
are `{ "message": "<text>" }`.
- **License validation.** `POST /api/v2/license`
(`src/routes/api/v2/license/+server.ts`): validates `license_key` +
`email_address`, requires the key be active and owned by the account, stamps
`LicenseKey.lastValidatedAt`, returns `serializeLicense({key, emailAddress})`
(`src/lib/server/v2-serializers.ts`). A v1 variant exists
(`src/routes/api/v1/license/+server.ts`).
- **Schedule storage.** `Schedule` is **per user** (`userId String @unique`,
`prisma/schema.prisma:263`), daily-only, `0..4` `Block`s. `Block`
(`:275`) stores `startMinutes`/`endMinutes`, `dynamicAudio Boolean
@default(false)`, and ordered `BlockProclamations` (`sortOrder`). The schema
comment states overnight ranges are disallowed in v1, yet the serializer and
Kiosk both support `overnight` — a known inconsistency.
- **Schedule service/validation.** `src/lib/server/schedule.ts` (DB
orchestration: `getOrCreateSchedule`, `listBlocks`, `createBlock`,
`updateBlock`, `deleteBlock`) and the isomorphic
`src/lib/schedule-utils.ts` (`MAX_BLOCKS = 4`, `SNAP_MINUTES = 15`,
`validateBlock`, `findOverlap`, `isOvernight`, `blockDuration`,
`blockSegments`, `formatTime`, `toHm`, `snapMinutes`). Validation enforces a
4-block maximum on create, `startMinutes !== endMinutes`, at least one
Proclamation, and no overlaps (overnight-aware via linear segments).
- **Schedule read API.** `GET /api/v2/schedule`
(`src/routes/api/v2/schedule/+server.ts`): auth by paired `email` +
`licenseKey` (legacy alias `key`) query params; checks only that the key
belongs to the email's account (entitlement deliberately not checked); rate
limited 10/min/IP; returns `{email, blocks: [serializeScheduleBlock(...)]}`
where `serializeScheduleBlock` (`src/lib/server/v2-serializers.ts:165`) emits
`{id, startTime, startMinutes, endTime, endMinutes, overnight,
durationMinutes, dynamicAudio, proclamationIds}`.
- **Schedule UI.** Protected user route
`src/routes/(protected)/(user)/schedule/` (`+page.server.ts`,
`+page.svelte`) with `src/lib/components/schedule/DaySchedule.svelte` (single
24h day timeline, drag-to-create) and `BlockModal.svelte`. Dashboard
(`(protected)/(user)/dashboard/`) lists the user's License Keys. No System
Name is displayed or editable.
### Gaps between current state and target
- Schedule is per-user, not per-License-Key.
- No `daily`/`weekly` typing; no day-of-week storage or UI.
- No `version`/ETag, no conditional GET.
- Schedule GET requires `email` + `licenseKey`; there is no license-key-only
route, no `/api/v2/licenses/...` namespace, and no system-name route.
- `dynamicAudio` DB default is `false`; the target default for new Blocks is
`true`.
- No System Name field, validation, or display.
- Sync is opportunistic with no boot/network-up/5-minute cadence and no
atomic cache file.
- Portal stores no timezone (matches target) and shows no informational
geoip-derived timezone.
---
## Time semantics
- Times are **minute precision**, stored as 24-hour `"HH:MM"` strings
representing **local wall-clock** time. AM/PM is display-only.
- Numerators may also be carried as integer minutes-from-midnight
(`startMinutes`/`endMinutes`, `0..1440`) for the Kiosk; the string form is
canonical on the wire.
- A Block is the **half-open interval `[start, end)`**.
- A Block may start on the same minute the previous Block ends (`end 07:00`,
next `start 07:00` is valid and has no gap).
- A Block whose `end` is earlier than its `start` **crosses midnight** and
belongs to its start day. In weekly mode, Sunday's crossing Block spills into
Monday. `start == end` is **invalid** (zero- or full-length ambiguity).
- **Overlaps are invalid**, including spillover from a midnight-crossing Block
into the next day's Blocks (or, in daily mode, into the same list's early
Blocks). Touching boundaries are **not** overlaps.
- Gaps between Blocks are allowed. During a gap the Kiosk plays whatever
Proclamations are selected locally on the Kiosk.
- The Kiosk evaluates Schedules in its own Timezone setting
(`config.timezone`, IANA code). **The payload carries no timezone.** The
Portal may show the user's geoip-derived timezone informationally only.
---
## Schedule data model (JSON)
Each License Key has **at most one** Schedule. `type` is `"daily"` or
`"weekly"` and determines the mode; there is no separate global mode. **No**
Schedule means `type` is `null`.
Suggested shape from the brief, adapted to existing field names (`start`/`end`
→ `startTime`/`endTime`, retaining the existing `startMinutes`/`endMinutes`
integers for the Kiosk):
```json
{
"version": "<opaque string; also used as the ETag>",
"type": "daily" | "weekly" | null,
"daily": [ Block, ... ],
"weekly": { "mon": [Block], "tue": [Block], "wed": [Block],
"thu": [Block], "fri": [Block], "sat": [Block],
"sun": [Block] },
"blocks": [ LegacyBlock, ... ]
}
```
Block:
```json
{
"id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f",
"startTime": "21:00",
"startMinutes": 1260,
"endTime": "09:00",
"endMinutes": 540,
"overnight": true,
"durationMinutes": 720,
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
"dynamicAudio": true
}
```
- `version` is an opaque string derived from the Schedule content (see
[Endpoints](#endpoints)). It is implementation-defined and must change
whenever the serialized schedule changes.
- **Only the fields for the active type are populated.** For `type: "daily"`,
`daily` is populated and `weekly` is omitted or empty. For `type: "weekly"`,
`weekly` is populated with all seven keys and `daily` is omitted or empty.
For `type: null`, both are empty/omitted.
- `weekly` always contains all seven lowercase keys `mon`..`sun`; a day with no
Blocks is an empty array.
- `blocks` is the **legacy flattening** retained for older Kiosks (see
[Backward compatibility](#backward-compatibility)). It uses the existing
`LegacyBlock` shape serialized by the current portal
(`{id, startTime, startMinutes, endTime, endMinutes, overnight,
durationMinutes, dynamicAudio, proclamationIds}`). For `type: "weekly"`,
`blocks` is an **empty array** (the server does not know the Kiosk's
timezone and cannot flatten weekly to a single day).
- `proclamationIds` is the ordered play list (order is significant);
duplicates are not meaningful and are de-duplicated by the Portal.
- `dynamicAudio` defaults to `true` for new Blocks created on the Portal.
- Proclamation **content** is never included; the Kiosk fetches content
separately via `/api/v2/proclamations`. Portal users only ever see
title/category/group.
---
## Validation rules
These are enforced by the Portal (authoritative) and defended against by the
Kiosk.
1. A Schedule belongs to exactly one License Key; a License Key has at most one
Schedule.
2. `type` is one of `"daily"`, `"weekly"`, or `null`.
3. A Block has `start` and `end` times; `start == end` is invalid.
4. `end < start` denotes a midnight-crossing Block belonging to its start day.
5. Blocks within a day (and the spillover of a midnight-crossing Block into the
following day) must not overlap. Touching boundaries are allowed.
6. Weekly modes apply rules 3–5 independently per day; a `sun` Block crossing
midnight must not overlap `mon` early Blocks.
7. A Block must contain at least one Proclamation. Empty Blocks are rejected
server-side. The Kiosk defensively **skips invalid Blocks** and treats them
as gaps.
8. Gaps are allowed; during a gap local Kiosk selection applies.
9. `dynamicAudio` is a boolean; absent means `true` for new Blocks.
10. Times are minute-precision `HH:MM` strings; the Portal snaps UI input to
`SNAP_MINUTES` (15) with a `MINUTE_STEP` (5) constraint from the existing
helpers.
11. The existing Portal cap of `MAX_BLOCKS = 4` per day list is **retained**
for Phase 0 (see [Open items](#open-items)).
The Kiosk must not crash on, and must not play, an invalid Block: it skips it.
---
## Endpoints
The API remains **unauthenticated** (no API keys) for v2. This is a known
limitation and is recorded under [Open items](#open-items).
All new endpoints live under the existing versioned namespace
`/api/v2/...` to preserve conventions. v1 routes are unchanged.
### (a) License validation — existing, additive
`POST /api/v2/license`
Request (existing fields unchanged; `systemName` additive and optional):
```json
{
"license_key": "ABCD-EFGH-IJKL-MNOP",
"email_address": "jane@example.com",
"systemName": "Living Room"
}
```
Response (existing fields unchanged; `systemNameStatus` additive):
```json
{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" }
```
- `systemName` is optional. When present it is trimmed; when absent the stored
name is left untouched.
- `systemNameStatus` ∈ `{"ok", "unchanged", "conflict", "invalid"}`:
- `ok` — name was set or updated.
- `unchanged` — omitted, or identical after trim/case-insensitive compare.
- `conflict` — another License Key on the same account already holds the name.
- `invalid` — trimmed length outside 1–40.
- **Validation itself succeeds regardless of name status.** A non-`ok` status
never fails the license check; the Kiosk treats it as advisory.
- Errors and rate limiting follow the existing endpoint (400 messages,
413 body cap, 429 with `Retry-After`).
### (b) Set System Name — Kiosk-facing
`PUT /api/v2/licenses/{licenseKey}/system-name`
Request:
```json
{ "systemName": "Living Room" }
```
Responses:
| Status | Condition | Body |
| --------------------- | ----------------------------------------- | ------------------------------------------------- |
| 200 | Name set/updated (or unchanged) | `{"systemName": "...", "systemNameStatus": "ok"}` |
| 400 `invalid_name` | Trimmed length outside 1–40 | `{"message": "invalid_name"}` |
| 404 `unknown_license` | No License Key matches | `{"message": "unknown_license"}` |
| 409 `name_taken` | Another key on the account holds the name | `{"message": "name_taken"}` |
- The Kiosk is the **only** writer. The Portal has no rename capability.
- The response body uses the existing v2 error convention
(`{"message": "<code>"}`).
- Rate limited at the existing 10 requests/min/IP convention.
### (c) Get Schedule — Kiosk-facing, key-only, conditional
`GET /api/v2/licenses/{licenseKey}/schedule`
- 200 with `ETag: "<version>"` and the payload from
[Schedule data model](#schedule-data-model-json). A License Key with no
Schedule returns 200 with `type: null` and empty lists.
- `304 Not Modified` when the request's `If-None-Match` matches the current
`ETag`.
- `404` (`unknown_license`) when no License Key matches.
- `version` doubles as the ETag value; the header value is the quoted form.
- The response is the **target** payload (`version`, `type`, `daily`, `weekly`,
`blocks`).
- Rate limited at the existing 10 requests/min/IP convention.
### (d) Legacy schedule read — existing, evolved additively
`GET /api/v2/schedule?email=<email>&licenseKey=<key>` remains supported with
its existing auth semantics. Its response is extended **additively** with the
same `version`, `type`, `daily`, and `weekly` fields, while continuing to
populate `email` and `blocks` exactly as before. Old Kiosks ignore the new
fields; new Kiosks may read either endpoint. This endpoint does **not** emit an
ETag and does **not** return 304.
### (e) Portal-internal endpoints (not Kiosk-facing)
These are session-authenticated Portal actions; they are **not** part of the
Kiosk API and are not served by `/api/v2/...`.
- Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on
`(protected)/(user)/schedule` (`?/setType`, `?/createBlock`, `?/updateBlock`,
`?/deleteBlock`), backed by `$lib/server/schedule.ts`.
- **Copy** — implemented in Phase 4 as the `?/copy` action plus
`copySchedule()` in `$lib/server/schedule.ts`. Copies one License Key's
Schedule to one or more other License Keys **by value, in a single database
transaction** (all targets succeed or none). The target `type` becomes the
source's; Block rows get fresh IDs; each block's `dynamicAudio` and ordered
Proclamation references are preserved. The source and every target must
belong to the caller's account; a target equal to the source, an empty
target list, or a source without a schedule are rejected. The resulting
schedule is run through the shared validator before commit. Because Block IDs
are part of the serialized payload, every target's content-derived
`version`/ETag changes, so its Kiosk refetches.
---
## Sync behavior
Target Kiosk sync (defaults; adjust only if the code forces it):
- The Kiosk pulls the Schedule:
- on boot,
- on network-up,
- every 5 minutes.
- Pulls use conditional GET (`If-None-Match` / ETag); a `304` leaves the cache
untouched.
- The last Schedule is cached to disk with an **atomic write** and used offline.
- The schedule on/off toggle is **Kiosk-local** (existing
`proclamation.schedule_active`) and is not synced.
- With no cache and no network, the Kiosk behaves as if schedules are off
(local Proclamation selection applies).
- During gaps, or when schedules are off, the local audio selection applies.
Current-state deltas (to be closed by later phases): no boot/network-up/5-minute
cadence, no ETag, no explicit atomic cache file. These are recorded under
[Touchpoints per phase](#touchpoints-per-phase).
Identity for sync is the License Key. The legacy schedule endpoint additionally
requires the paired email; the new endpoint uses the License Key alone.
---
## System Name rules
- A System Name is a human-readable label for a License Key (e.g.
"Living Room"). It is a **label only** — never used as a reference or key.
- Unique **per account**, compared **case-insensitively**.
- Trimmed, **1–40 characters**.
- Set or changed **only via the Kiosk**. The Portal displays it **read-only**
and has no rename capability.
- The Portal falls back to displaying the License Key when no name is set.
- A Kiosk that omits `systemName` never clears an existing name.
- Wire representations: request field `systemName`; status field
`systemNameStatus` ∈ `{"ok","unchanged","conflict","invalid"}` (see
[Endpoints](#endpoints)).
---
## Dynamic audio
- Each Block has a boolean `dynamicAudio`.
- The Portal defaults it to **`true` for new Blocks**.
- `true` ⇒ the Kiosk uses dynamic audio for that Block.
- `false` ⇒ the Kiosk uses its locally chosen audio.
- During gaps, or when schedules are off, the local audio selection applies
(existing `config.audio.dynamic_audio` / fixed track).
- Kiosk parsing defaults an absent `dynamicAudio` to `true`.
---
## Backward compatibility
All existing endpoints and payloads keep working for Kiosks already in the
field. New fields are **additive**; no v2 field is removed or renamed, and v1
is untouched.
- **Old Kiosk vs new Portal.** An old Kiosk calls
`POST /api/v2/license` (no `systemName`) and
`GET /api/v2/schedule?email&licenseKey`, reading `blocks` and ignoring
unknown fields. The Portal keeps both, keeps the `blocks` shape byte-
compatible, and adds `version`/`type`/`daily`/`weekly` as extra fields. The
old Kiosk is unaffected and ignores `systemNameStatus`.
- **Daily schedules.** For `type: "daily"`, `blocks` is the flattened daily
list; an old Kiosk behaves exactly as today.
- **Weekly schedules.** `blocks` is an **empty array**. An old Kiosk therefore
sees no schedule and falls back to local Proclamation/audio selection. It
cannot interpret weekly schedules; this is accepted and recorded under
[Open items](#open-items).
- **No schedule.** `type: null`, `blocks: []`; both old and new Kiosks fall
back to local selection.
- **Omission of `systemName`** by an old Kiosk never clears an existing name;
`systemNameStatus` is simply ignored.
- The new endpoints are additive; their absence on old Kiosks is irrelevant.
---
## Touchpoints per phase
File/module candidates each later phase will change. Phase numbering is assumed
from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy)
and is recorded under [Open items](#open-items).
### Kiosk (`radiantwave`)
- **Phase 1 (Kiosk scheduler UI, storage, sync, playback)**
- `src/network/api_client.h` / `src/network/api_client.cpp` — parse
`version`/`type`/`daily`/`weekly`, ETag/`If-None-Match`, `PUT`
system-name, `systemNameStatus`.
- `src/network/proclamation_sync.h` / `.cpp` — boot/network-up/5-minute
schedule sync, conditional GET, atomic cache.
- `src/network/license_service.h` / `.cpp` — send `systemName`, handle
`systemNameStatus`.
- `src/db/db_keys.h` — System Name / ETag keys; `src/db/database_manager.h`
/ `.cpp` — schedule `type`/day-of-week storage, cache.
- `src/scroller_page.h` / `.cpp`, `src/scroller_streams.h` / `.cpp` —
day-of-week Block selection, spillover.
- `src/timezone_helper.h` — local day-of-week.
- `src/config/app_config.h`, `src/config/config_manager.cpp`,
`src/config/yaml_parser.cpp` — settings surface.
- New page(s) for setting the System Name; tests in `tests/` registered in
`CMakeLists.txt`.
### Portal (`portal`)
- **Phase 2 (System Name + license validation)**
- `prisma/schema.prisma` + migration — `LicenseKey.systemName` and
case-insensitive uniqueness.
- `src/routes/api/v2/license/+server.ts` — `systemName` /
`systemNameStatus`.
- `src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts` — new.
- `src/lib/server/v2-serializers.ts`, `src/lib/server/v2-helpers.ts`.
- Dashboard read-only display.
- **Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)**
- `prisma/schema.prisma` + migration — Schedule per License Key, `type`,
day-of-week.
- `src/lib/server/schedule.ts`, `src/lib/schedule-utils.ts` — weekly,
per-license orchestration, cross-day validation.
- `src/lib/server/v2-serializers.ts` — `version`/`type`/`daily`/`weekly`.
- `src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts` — new
(ETag/304).
- `src/routes/api/v2/schedule/+server.ts` — additive evolution.
- `src/routes/(protected)/(user)/schedule/*`,
`src/lib/components/schedule/*` — weekly UI, per-license selection.
- Specs: `schedule.spec.ts`, `page.server.spec.ts`, `schedule-utils.spec.ts`.
- **Phase 4 (Schedule copy)**
- Copy service in `src/lib/server/schedule.ts`; copy route/action; UI.
- **All phases** — `docs/openapi.yaml`, this contract, `docs/fixtures/`.
---
## Open items
Recorded ambiguities, conservative choices, and remaining known limitations.
Items 1–21 were resolved or restated during Phases 1–4; items 22–24 are Phase 5
findings. Everything marked "Resolved" is settled for this overhaul.
1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only;
Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here.
2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the
Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no
schedule". Implemented in Phase 3.
3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained.
Phase 3 applies it **per day list** (each of the seven weekly days gets its
own 4-block budget). Whether the cap should be lower is unresolved.
4. **Field naming.** The brief's `start`/`end` were mapped to the existing
`startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk.
5. **`version` derivation.** Resolved in Phase 3: a SHA-1 content hash of the
serialized `{type, daily, weekly}` shape. Stable for equal content, changes
on any mutation.
6. **Unauthenticated key-only access.** The new endpoints authenticate by
License Key alone (per glossary). Anyone who knows a key could read the
schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known
limitation.
7. **`dynamicAudio` default flip.** DB default changed `false` → `true` for
**new** Blocks in Phase 3; existing Blocks keep their stored values.
8. **Per-user → per-License-Key migration.** Resolved in Phase 3: each existing
per-user Schedule is mapped to that account's **oldest** License Key
(`createdAt ASC`), as a daily schedule. Schedules with no owning
user/license are retained with `licenseKeyId NULL` and reported via a
migration `NOTICE` (not dropped).
9. **Cross-day spillover validation.** Implemented in Phase 3: weekly
`sun`→`mon` and daily self-wrap spillover are rejected as overlaps; the
shared `validateSchedule` is authoritative and mirrored client-side.
10. **Account scope for name uniqueness.** "Per account" = the owning User.
Legacy License Keys with only an `email` and no `userId` are an edge case
(written without a per-account uniqueness check).
11. **Geoip timezone display.** Phase 3 shows an informational note that times
run in the **Kiosk's** timezone and uses the **browser-resolved** timezone
as a fallback; no geoip dependency (paid or external) was added. The
Portal still stores no timezone.
12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no
ETag and never returns 304, to preserve old behaviour.
13. **`systemNameStatus` on v1.** Not added; v1 is unchanged.
14. **Trim/case representation.** Names are trimmed; uniqueness is
case-insensitive; the stored casing is preserved as provided.
15. **Sync cadence.** Resolved in Phase 3: the Kiosk loads its on-disk cache on
boot, then pulls on boot, on a 5-minute timer, and on demand.
16. **Atomic cache file.** Resolved in Phase 3:
`~/.local/radiantwave/schedule_cache.json`, written atomically (temp file +
rename); a corrupt cache is ignored.
17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent
or empty.
18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the
legacy schedule endpoint.
19. **Kiosk local toggle.** `proclamation.schedule_enabled` in `config.yaml`,
default **on**, surfaced as System Settings → Schedule. Not synced. When
off, the engine plays local selections but the cache keeps updating. The
older `proclamation.schedule_active` flag is retained in config but is not
consulted by the Phase 3 engine.
20. **Copy semantics.** Copies are **independent, by value** snapshots: no link
is kept to the source, and editing the source later does not affect targets
(and vice versa). A copy replaces any existing target Schedule in full
(type and blocks) and requires explicit confirmation in the UI when a target
already has one. No "copy to all" shortcut and no scheduled/linked copies
(per the Phase 4 brief). The copied schedule is validated before commit; a
source schedule that fails validation is rejected rather than copied.
21. **Copy and Kiosk code.** Verified against the Phase 3 Kiosk engine/fixture
tests that a copied payload (new Block IDs, same type/times/audio/refs) is
parsed and evaluated identically to any other; **no Kiosk changes were
required**.
22. **Phase 3 migration down is lossy for new-model data.** The `up` migration
(`20260929130000_move_schedule_to_license_key`) is additive and safe. Its
`down` path is **reversible in structure but lossy in data**: the down SQL
drops `Schedule.licenseKeyId`/`type` and `Block.day`, then deletes
`Schedule` rows whose `userId IS NULL`. Any schedule created under the
per-License-Key model (which sets `licenseKeyId` and leaves `userId` NULL)
is therefore **deleted** by a down-migration. Consequences:
- Back up `Schedule`/`Block`/`BlockProclamations` before any down-migration.
- Rolling back past Phase 3 requires restoring that data from backup; it is
**not** a data-preserving rollback.
- Rollback guidance is in
[scheduler-overhaul-release-notes.md](scheduler-overhaul-release-notes.md).
23. **Kiosk UI/E2E on real hardware not run here.** The verification ran the
Portal and the Kiosk schedule logic (engine, client, cache) via the test
harness and real HTTP against a locally running Portal; the SDL Kiosk UI was
not driven interactively because the build environment has no display. See
the manual checklist in the release notes and the Phase 5 report.
24. **Unauthenticated Kiosk endpoints remain a known limitation** (restated from
item 6): the key-only schedule and system-name endpoints authenticate by
License Key alone, mitigated only by the 10/min/IP rate limit. Authentication
is out of scope for this overhaul.
---
## Changelog
- 2026-09-29 — Initial contract created (Phase 0). Documents current state,
target time semantics, schedule data model, validation rules, endpoints
(`/api/v2/licenses/...`, additive license-validation fields), sync behavior,
System Name rules, dynamic audio, backward compatibility, touchpoints,
open items, and fixtures.
- 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and
`Block.day`; the shared `validateSchedule` is authoritative; the key-only
`GET /api/v2/licenses/{licenseKey}/schedule` (ETag/304) ships and the legacy
endpoint is extended additively; the Portal editor is per-system with a
browser-fallback timezone note; the Kiosk adds the conditional client,
atomic cache, schedule engine, and a System Settings On/Off toggle. Open
items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes.
- 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal
action (`?/copy`) backed by a single-transaction service that copies by
value with fresh Block IDs (so target ETags change), preserving `dynamicAudio`
and Proclamation order, scoped to the caller's account, and validated before
commit. Endpoint (e) is finalized; open items 20 and 21 record the copy
semantics and the Kiosk no-change verification.
- 2026-09-29 — Phase 5 (final). End-to-end verification against a locally
running Portal on a fresh database: schedule create/type/copy services and
the Kiosk-facing HTTP endpoints (validation with/without `systemName`,
system-name PUT, key-only schedule GET with ETag/304/404, legacy endpoint
parity, cross-account rejection) all pass; old-client compatibility confirmed;
migrations verified up/down (the down path is lossy, see open item 22). Adds
open items 22–24 (lossy down-migration, unrun UI-on-hardware checks, and the
restated unauthenticated-endpoint limitation) and the release notes.