docs: add Phase 0 schedule API/data contract and fixtures
Add docs/schedule-api-contract.md (current state, time semantics, schedule data model, validation rules, endpoints, sync behavior, System Name rules, dynamic audio, backward compatibility, per-phase touchpoints, open items, changelog) and docs/fixtures/*.json (daily, weekly, none, midnight-crossing, adjacent, dynamic-audio, and legacy v2 payloads). Kept byte-identical with the portal repo.
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "20:30",
|
||||||
|
"endMinutes": 1230,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 150,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-midnight-0001",
|
||||||
|
"type": "daily",
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "cc111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "21:00",
|
||||||
|
"startMinutes": 1260,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": true,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
],
|
||||||
|
"dynamicAudio": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cc222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "09:00",
|
||||||
|
"startMinutes": 540,
|
||||||
|
"endTime": "21:00",
|
||||||
|
"endMinutes": 1260,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
|
||||||
|
"dynamicAudio": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "cc111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "21:00",
|
||||||
|
"startMinutes": 1260,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": true,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cc222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "09:00",
|
||||||
|
"startMinutes": 540,
|
||||||
|
"endTime": "21:00",
|
||||||
|
"endMinutes": 1260,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-none-0001",
|
||||||
|
"type": null,
|
||||||
|
"daily": [],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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=<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 (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.
|
||||||
Reference in New Issue
Block a user