diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a7a7a2a..1d70c1a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -245,13 +245,16 @@ paths: pairing. Returns every Block with its start/end times and the ordered list of - selected Proclamation IDs. Overnight blocks (an end clock time before - the start) are supported and flagged with `overnight: true` and a - wrapping `durationMinutes`. Each block also carries `dynamicAudio`; - when true, the kiosk enables dynamic audio for the block's duration, - overriding any audio selections made on the device. Proclamation - content is not included; the kiosk fetches it separately via - `/api/v2/proclamations`. + selected Proclamation IDs (the legacy daily flattening in `blocks`), and + is extended additively with `version`, `type`, `daily`, and `weekly`. + Overnight blocks (an end clock time before the start) are supported and + flagged with `overnight: true` and a wrapping `durationMinutes`. Each + block also carries `dynamicAudio`; when true, the kiosk enables dynamic + audio for the block's duration, overriding any audio selections made on + the device. Proclamation content is not included; the kiosk fetches it + separately via `/api/v2/proclamations`. For `type: "weekly"`, `blocks` is + an empty array (the server cannot flatten weekly in the kiosk's + timezone). This endpoint emits no ETag and never returns 304. operationId: getSchedule parameters: - in: query @@ -309,6 +312,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorMessage' + /api/v2/licenses/{licenseKey}/schedule: + get: + tags: [Schedule] + summary: Get a License Key's schedule (Kiosk-facing, conditional) + description: | + Kiosk-facing, License-Key-only schedule read. Returns the target payload + (`version`, `type`, `daily`, `weekly`, `blocks`). `version` is a + content-derived opaque string and is also the `ETag`; a matching + `If-None-Match` returns `304`. An unknown License Key is `404`; a key + with no Schedule returns `type: null` with empty lists. No + authentication (see the API description) beyond the 10/min/IP limit. + operationId: getLicenseSchedule + parameters: + - in: path + name: licenseKey + required: true + schema: + type: string + pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + - in: header + name: If-None-Match + required: false + schema: + type: string + description: Prior ETag; when it matches, returns 304. + responses: + '200': + description: The target schedule payload. + headers: + ETag: + schema: + type: string + description: The quoted `version`. + content: + application/json: + schema: + $ref: '#/components/schemas/SchedulePayload' + '304': + description: Not modified; the caller keeps its cached payload. + headers: + ETag: + schema: + type: string + '404': + description: 'Unknown license (`unknown_license`).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + '429': + description: Rate limit exceeded (10 requests per minute per IP). + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' /api/v2/licenses/{licenseKey}/system-name: put: tags: [License] @@ -661,9 +724,51 @@ components: email: type: string format: email + version: + type: string + type: + type: string + nullable: true + enum: [daily, weekly, null] + daily: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' + weekly: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' blocks: type: array - maxItems: 4 + items: + $ref: '#/components/schemas/ScheduleBlock' + SchedulePayload: + type: object + required: [version, type, daily, weekly, blocks] + properties: + version: + type: string + description: Opaque content hash; doubles as the ETag. + type: + type: string + nullable: true + enum: [daily, weekly, null] + daily: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' + weekly: + type: object + description: Present with all seven keys when `type` is weekly. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' + blocks: + type: array + description: Legacy daily flattening; empty for weekly. items: $ref: '#/components/schemas/ScheduleBlock' OnboardingRequest: diff --git a/docs/schedule-api-contract.md b/docs/schedule-api-contract.md index 9e0dda6..91ec87b 100644 --- a/docs/schedule-api-contract.md +++ b/docs/schedule-api-contract.md @@ -495,45 +495,55 @@ later phase. 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. + 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.** Algorithm (content hash vs max `updatedAt` + - shape) deferred to implementation; it must be stable for equal content and - change on mutation. +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 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. +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. -11. **Geoip timezone display.** Informational only, not implemented in the - Portal today. + 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.** 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. +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. --- @@ -544,3 +554,10 @@ later phase. (`/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.